From 2197aad4e965567e71f6a0a7ca1f637c28e27fee Mon Sep 17 00:00:00 2001 From: Mouignihazi <165266469+Mouignihazi@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:22:29 +0200 Subject: [PATCH] Admin : page admin + responsive + CookieBanner + Toast + BackToTop --- .gitignore | 9 + index.html | 2 +- src/App.module.css | 1 + src/App.tsx | 12 +- src/components/AdminRoute.tsx | 10 + src/components/BackToTop.tsx | 15 ++ src/components/CookieBanner.tsx | 91 +++++---- src/components/Sidebar.tsx | 26 ++- src/components/Toast.tsx | 36 ++++ src/pages/Admin.tsx | 296 ++++++++++++++++++++++++++++ src/pages/AuthPage.tsx | 2 +- src/pages/Documents.tsx | 71 +++++-- src/pages/IngredientSearchPage.tsx | 109 +++++++--- src/pages/RecentUpdatesPage.tsx | 69 +++++-- src/services/theapi.ts | 3 +- src/styles/Admin.module.css | 58 ++++++ src/styles/BackToTop.module.css | 1 + src/styles/CookieBanner.module.css | 117 ++++++----- src/styles/DocumentsPage.module.css | 13 +- src/styles/Sidebar.module.css | 56 +++++- src/styles/Toast.module.css | 5 + src/styles/global.css | 4 + 22 files changed, 821 insertions(+), 185 deletions(-) create mode 100644 src/components/AdminRoute.tsx create mode 100644 src/components/BackToTop.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/pages/Admin.tsx create mode 100644 src/styles/Admin.module.css create mode 100644 src/styles/BackToTop.module.css create mode 100644 src/styles/Toast.module.css diff --git a/.gitignore b/.gitignore index a547bf3..920aadb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,12 @@ dist-ssr *.njsproj *.sln *.sw? + +@" +node_modules/ +dist/ +.env +.env.local +.DS_Store +*.log +"@ \ No newline at end of file diff --git a/index.html b/index.html index bedd93b..0fca6f0 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - RegWatch + frontend
diff --git a/src/App.module.css b/src/App.module.css index 174edb0..7a51851 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -20,6 +20,7 @@ } @media (max-width: 768px) { + .contentWrapper{flex-direction:column} .main { margin-left: 0; } diff --git a/src/App.tsx b/src/App.tsx index c95e212..3b19439 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,10 +10,14 @@ import AboutPage from "./pages/AboutPage"; import ContactPage from "./pages/ContactPage"; import CGU from "./pages/CGU"; import MentionsLegales from "./pages/MentionsLegales"; +import Admin from "./pages/Admin"; +import AdminRoute from "./components/AdminRoute"; import Sidebar from "./components/Sidebar"; import Footer from "./components/Footer"; import CookieBanner from "./components/CookieBanner"; +import Toasts from "./components/Toast"; +import BackToTop from "./components/BackToTop"; import styles from "./App.module.css"; @@ -27,7 +31,8 @@ type NavItem = | "about" | "contact" | "cgu" - | "mentions-legales"; + | "mentions-legales" + | "admin"; interface User { id: number; @@ -96,6 +101,7 @@ export default function App() { : location.pathname === "/contact" ? "contact" : location.pathname === "/cgu" ? "cgu" : location.pathname === "/mentions-legales"? "mentions-legales" + : location.pathname === "/admin"? "admin" : "home"; useEffect(() => { @@ -120,6 +126,7 @@ export default function App() { : item === "about" ? "/about" : item === "contact" ? "/contact" : item === "cgu" ? "/cgu" + : item === 'admin' ? '/admin' : "/mentions-legales"; navigate(path); }; @@ -144,6 +151,7 @@ export default function App() { return (
+
} /> } /> } /> + } /> } />
); } \ No newline at end of file diff --git a/src/components/AdminRoute.tsx b/src/components/AdminRoute.tsx new file mode 100644 index 0000000..aaa8c45 --- /dev/null +++ b/src/components/AdminRoute.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { Navigate } from 'react-router-dom' + +type Props = { user?: unknown, children: React.ReactNode } + +export default function AdminRoute({ user, children }: Props){ + const role = user && typeof user === 'object' && 'role' in user ? (user as { role?: string }).role : undefined + if(role === 'admin') return <>{children} + return +} diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx new file mode 100644 index 0000000..53af07d --- /dev/null +++ b/src/components/BackToTop.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' +import css from '../styles/BackToTop.module.css' + +export default function BackToTop(){ + const [visible, setVisible] = useState(false) + useEffect(()=>{ + const onScroll = () => setVisible(window.scrollY > 300) + window.addEventListener('scroll', onScroll) + return ()=> window.removeEventListener('scroll', onScroll) + },[]) + if(!visible) return null + return ( + + ) +} diff --git a/src/components/CookieBanner.tsx b/src/components/CookieBanner.tsx index bf71309..b8d5941 100644 --- a/src/components/CookieBanner.tsx +++ b/src/components/CookieBanner.tsx @@ -1,57 +1,54 @@ -import { useState, useEffect } from "react"; +import { useState } from 'react' +import styles from '../styles/CookieBanner.module.css' -const KEY = "cookieConsentAccepted"; +const STORAGE_KEY = 'cookieConsentAccepted' export default function CookieBanner() { - const [visible, setVisible] = useState(false); - - useEffect(() => { - const consent = localStorage.getItem(KEY); - if (!consent) { - setVisible(true); + const [visible, setVisible] = useState(() => { + try { + const val = localStorage.getItem(STORAGE_KEY) + return val !== 'true' + } catch { + return true } - }, [setVisible]); + }) - const handleAccept = () => { - localStorage.setItem(KEY, "true"); - setVisible(false); - }; + const accept = () => { + try { + localStorage.setItem(STORAGE_KEY, 'true') + } catch (err) { void err } + setVisible(false) + } - if (!visible) return null; + const decline = () => { + try { + localStorage.setItem(STORAGE_KEY, 'false') + } catch (err) { void err } + setVisible(false) + } + + if (!visible) return null return ( -
-

- Ce site utilise des cookies pour améliorer votre expérience utilisateur. -

- +
+
+
+ Ce site utilise des cookies pour améliorer votre expérience. En + continuant, vous acceptez l'utilisation des cookies. +
+ +
+ + + + En savoir plus + +
+
- ); + ) } \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a88528e..bec35cc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ -import { Home, Search, FileText, Clock, LogOut } from 'lucide-react'; +import React, { useState } from 'react' +import { Home, Search, FileText, Clock, LogOut, Settings } from 'lucide-react'; import styles from '../styles/Sidebar.module.css'; type NavItem = @@ -9,12 +10,14 @@ type NavItem = | 'about' | 'contact' | 'cgu' - | 'mentions-legales'; + | 'mentions-legales' + | 'admin'; interface User { id: number; email: string; full_name: string | null; + role?: string; } interface SidebarProps { @@ -30,6 +33,7 @@ export default function Sidebar({ onLogout, user, }: SidebarProps) { + const [open, setOpen] = useState(false) const navItems: { key: NavItem; label: string; @@ -41,6 +45,10 @@ export default function Sidebar({ { key: 'recent-updates', label: 'Recent Updates', icon: }, ]; + if (user?.role === 'admin') { + navItems.push({ key: 'admin', label: 'Administration', icon: }) + } + const initials = user ? (user.full_name || user.email) .split(/[ .@_-]+/) @@ -52,7 +60,13 @@ export default function Sidebar({ : ''; return ( - + + ); } \ No newline at end of file diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..0b96626 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' +import css from '../styles/Toast.module.css' + +type ToastItem = { id: string; message: string; type?: 'info'|'success'|'error' } + +export default function Toasts(){ + const [toasts, setToasts] = useState([]) + + useEffect(()=>{ + const handler = (ev: Event) => { + const detail = (ev as CustomEvent>).detail || {} + const d = detail as Record + const message = typeof d.message === 'string' ? d.message : '' + const type = d.type === 'success' || d.type === 'error' ? (d.type as 'success'|'error') : 'info' + const t: ToastItem = { id: String(Date.now()) + Math.random().toString(36).slice(2,6), message, type } + setToasts(s=>[t, ...s]) + setTimeout(()=>{ + setToasts(s=>s.filter(x=>x.id!==t.id)) + }, 4000) + } + window.addEventListener('app:toast', handler as EventListener) + return ()=> window.removeEventListener('app:toast', handler as EventListener) + },[]) + + if(toasts.length===0) return null + + return ( +
+ {toasts.map(t=> ( +
+ {t.message} +
+ ))} +
+ ) +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..3cdd67f --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useState } from 'react' +import styles from '../styles/Admin.module.css' +import { FileText, Database, Users } from 'lucide-react' + +type User = { id: string; email: string; full_name?: string; role: 'admin' | 'user' } +type Document = { id: string; title: string; source: 'CIR' | 'SCCS'; ingredient?: string; date?: string } + +type ScraperLog = { id: string; when: string; source: string; status: 'pending' | 'running' | 'success' | 'error' } +type Stats = { total_documents?: number; cir?: number; sccs?: number; users?: number } + +export default function Admin() { + const [tab, setTab] = useState<'users'|'documents'|'stats'|'scrapers'>('users') + + const [users, setUsers] = useState([]) + const [docs, setDocs] = useState([]) + const [stats, setStats] = useState(null) + const [logs, setLogs] = useState([]) + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [confirmOpen, setConfirmOpen] = useState(false) + const [confirmAction, setConfirmAction] = useState<() => void>(()=>{}) + const [confirmText, setConfirmText] = useState('') + + const [search, setSearch] = useState('') + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState<'asc'|'desc'>('asc') + + const token = '' // token should be passed / read from auth in real app + + useEffect(() => { + const fetchTab = async () => { + setError(null) + setLoading(true) + try{ + if(tab==='users'){ + const res = await fetch(`/admin/users?token=${token}`) + if(!res.ok) throw new Error('Impossible de charger les utilisateurs') + const data = await res.json() + setUsers(data || []) + } else if(tab==='documents'){ + const res = await fetch('/documents') + if(!res.ok) throw new Error('Impossible de charger les documents') + const data = await res.json() + setDocs(data || []) + } else if(tab==='stats'){ + const res = await fetch(`/admin/stats?token=${token}`) + if(!res.ok) throw new Error('Impossible de charger les statistiques') + const data = await res.json() + setStats(data) + } else if(tab==='scrapers'){ + // logs endpoint (fallback) + const res = await fetch(`/admin/scrapers/logs?token=${token}`) + if(!res.ok) throw new Error('Impossible de charger les logs de scrapers') + const data = await res.json() + setLogs(data || []) + } + }catch(e: unknown){ + const msg = e instanceof Error ? e.message : String(e) + setError(msg || 'Erreur réseau') + }finally{setLoading(false)} + } + + fetchTab() + }, [tab]) + + function openConfirm(text: string, action: ()=>void){ + setConfirmText(text) + setConfirmAction(()=>action) + setConfirmOpen(true) + } + + async function deleteUser(id: string){ + try{ + setLoading(true) + const res = await fetch(`/admin/users/${id}?token=${token}`, { method: 'DELETE' }) + if(!res.ok) throw new Error('Suppression impossible') + setUsers(u=>u.filter(x=>x.id!==id)) + window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'Utilisateur supprimé', type: 'success' } })) + }catch(e: unknown){ + const msg = e instanceof Error ? e.message : String(e) + setError(msg||'Erreur') + window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) + }finally{setLoading(false)} + } + + async function toggleRole(u: User){ + try{ + setLoading(true) + const newRole = u.role === 'admin' ? 'user' : 'admin' + const res = await fetch(`/admin/users/${u.id}/role?token=${token}`, { method: 'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ role: newRole }) }) + if(!res.ok) throw new Error('Impossible de modifier le rôle') + setUsers(list=>list.map(x=> x.id===u.id ? {...x, role:newRole} : x)) + window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Rôle mis à jour: ${newRole}`, type: 'success' } })) + }catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) } + } + + async function deleteDocument(id:string){ + try{ + setLoading(true) + const res = await fetch(`/admin/documents/${id}?token=${token}`, { method: 'DELETE' }) + if(!res.ok) throw new Error('Suppression impossible') + setDocs(d=>d.filter(x=>x.id!==id)) + window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'Document supprimé', type: 'success' } })) + }catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) } + } + + async function runScraper(source: 'CIR'|'SCCS'){ + try{ + setLoading(true) + const res = await fetch(`/admin/scrapers/run?source=${source}&token=${token}`, { method: 'POST' }) + if(!res.ok) throw new Error('Erreur lors du lancement') + const entry = await res.json() + setLogs(l=>[entry, ...l].slice(0,5)) + window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Scraper ${source} lancé`, type: 'success' } })) + }catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) } + } + + const filteredDocs = docs.filter(d => d.title.toLowerCase().includes(search.toLowerCase()) || (d.ingredient||'').toLowerCase().includes(search.toLowerCase())) + + const sortedUsers = React.useMemo(()=>{ + if(!sortKey) return users + return [...users].sort((a: User, b: User) =>{ + const ra = a as unknown as Record + const rb = b as unknown as Record + const va = typeof ra[sortKey as string] === 'string' ? (ra[sortKey as string] as string) : '' + const vb = typeof rb[sortKey as string] === 'string' ? (rb[sortKey as string] as string) : '' + if(typeof va === 'string' && typeof vb === 'string'){ + return sortDir==='asc' ? va.localeCompare(vb) : vb.localeCompare(va) + } + return 0 + }) + },[users, sortKey, sortDir]) + + const sortedDocs = React.useMemo(()=>{ + if(!sortKey) return filteredDocs + return [...filteredDocs].sort((a: Document, b: Document) =>{ + const ra = a as unknown as Record + const rb = b as unknown as Record + const va = typeof ra[sortKey as string] === 'string' ? (ra[sortKey as string] as string) : '' + const vb = typeof rb[sortKey as string] === 'string' ? (rb[sortKey as string] as string) : '' + if(typeof va === 'string' && typeof vb === 'string'){ + return sortDir==='asc' ? va.localeCompare(vb) : vb.localeCompare(va) + } + return 0 + }) + },[filteredDocs, sortKey, sortDir]) + + return ( +
+
+

Administration

+ +
+ +
+ {loading &&
Chargement...
} + {error &&
{error}
} + + {tab==='users' && ( +
+
+
{users.length} utilisateurs au total
+
+
+ + + + + + + + + + + + {sortedUsers.map(u=> ( + + + + + + + + ))} + +
{ setSortKey('id'); setSortDir(sortDir==='asc'?'desc':'asc') }}>ID{ setSortKey('email'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Email{ setSortKey('full_name'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Nom complet{ setSortKey('role'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Rôle
{u.id}{u.email}{u.full_name||'-'}{u.role} + + +
+
+
+ )} + + {tab==='documents' && ( +
+
+
{docs.length} documents au total
+ setSearch(e.target.value)} /> +
+ +
+ + + + + + + + + + + + + {sortedDocs.map(d=> ( + + + + + + + + + ))} + +
{ setSortKey('id'); setSortDir(sortDir==='asc'?'desc':'asc') }}>ID{ setSortKey('title'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Title{ setSortKey('source'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Source{ setSortKey('ingredient'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Ingredient{ setSortKey('date'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Date
{d.id}{d.title}{d.source}{d.ingredient||'-'}{d.date||'-'}
+ + {filteredDocs.length===0 &&
Aucun document trouvé.
} +
+
+ )} + + {tab==='stats' && ( +
+
+
Total documents
{stats?.total_documents ?? '-'}
+
Documents CIR
{stats?.cir ?? '-'}
+
Documents SCCS
{stats?.sccs ?? '-'}
+
Total utilisateurs
{stats?.users ?? '-'}
+
+ +
+
CIR vs SCCS
+
+
CIR
{stats?.cir||0}
+
SCCS
{stats?.sccs||0}
+
+
+
+ )} + + {tab==='scrapers' && ( +
+
+
Scrapers
+
+
+
+ + +
+
+

Dernières exécutions

+
    + {logs.map(l=> ( +
  • {l.when} — {l.source} — {l.status}
  • + ))} + {logs.length===0 &&
  • Aucune exécution.
  • } +
+
+
+
+ )} +
+ + {confirmOpen && ( +
+
+

{confirmText}

+
+ + +
+
+
+ )} + +
+ ) +} diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx index 4983d84..56cbdbf 100644 --- a/src/pages/AuthPage.tsx +++ b/src/pages/AuthPage.tsx @@ -9,7 +9,7 @@ export default function AuthPage({ onAuthSuccess }: { onAuthSuccess: (token: str const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + const API_URL = "http://localhost:8000"; const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/pages/Documents.tsx b/src/pages/Documents.tsx index 7e04d98..6c3b738 100644 --- a/src/pages/Documents.tsx +++ b/src/pages/Documents.tsx @@ -18,11 +18,12 @@ type ApiDocument = { bold?: boolean; }; -const ITEMS_PER_PAGE = 8; +const DEFAULT_ITEMS_PER_PAGE = 8; export default function DocumentsPage() { const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); + const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE); const [sourceFilter, setSourceFilter] = useState("All Sources"); const [typeFilter, setTypeFilter] = useState("All Types"); @@ -94,16 +95,29 @@ export default function DocumentsPage() { const totalPages = Math.max( 1, - Math.ceil(filtered.length / ITEMS_PER_PAGE) + Math.ceil(filtered.length / itemsPerPage) ); const safePage = Math.min(currentPage, totalPages); const paginated = filtered.slice( - (safePage - 1) * ITEMS_PER_PAGE, - safePage * ITEMS_PER_PAGE + (safePage - 1) * itemsPerPage, + safePage * itemsPerPage ); + // adjust items per page based on viewport width + useEffect(() => { + const apply = () => { + const w = window.innerWidth; + if (w < 640) setItemsPerPage(3); + else if (w < 900) setItemsPerPage(6); + else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE); + }; + apply(); + window.addEventListener('resize', apply); + return () => window.removeEventListener('resize', apply); + }, []); + if (loading) { return (
@@ -246,21 +260,40 @@ export default function DocumentsPage() { Previous - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (page) => ( - - ) - )} + {/** Render a limited set of page numbers for readability */} + {(() => { + const maxShow = 5; + const pages: (number | string)[] = []; + if (totalPages <= maxShow) { + for (let p = 1; p <= totalPages; p++) pages.push(p); + } else { + if (safePage <= 3) { + pages.push(1,2,3,4,'...', totalPages); + } else if (safePage >= totalPages - 2) { + pages.push(1,'...', totalPages-3, totalPages-2, totalPages-1, totalPages); + } else { + pages.push(1,'...', safePage-1, safePage, safePage+1,'...', totalPages); + } + } + + return pages.map((p, idx) => { + if (p === '...') return ; + const page = p as number; + return ( + + ); + }); + })()}
) : ( - filtered.map((doc) => ( -
-
-
-

{doc.title}

-
-
- Ingredient - {doc.ingredient} -
-
- Source - {doc.source} -
-
- Type - {doc.type} -
-
- Date - {doc.date ?? "—"} + (() => { + const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage)); + const safePage = Math.min(currentPage, totalPages); + const paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage); + return ( + <> + {paginated.map((doc) => ( +
+
+
+

{doc.title}

+
+
+ Ingredient + {doc.ingredient} +
+
+ Source + {doc.source} +
+
+ Type + {doc.type} +
+
+ Date + {doc.date ?? "—"} +
+
+
+ + {doc.pdf_url && ( + + )}
-
+ ))} - {doc.pdf_url && ( - + {totalPages > 1 && ( +
+ + {(() => { + const maxShow = 5; + const pages: (number | string)[] = []; + if (totalPages <= maxShow) { + for (let p = 1; p <= totalPages; p++) pages.push(p); + } else { + if (currentPage <= 3) pages.push(1,2,3,4,'...', totalPages); + else if (currentPage >= totalPages - 2) pages.push(1,'...', totalPages-3, totalPages-2, totalPages-1, totalPages); + else pages.push(1,'...', currentPage-1, currentPage, currentPage+1,'...', totalPages); + } + return pages.map((p, idx) => p === '...' ? : ( + + )); + })()} + +
)} -
-
- )) + + ); + })() )}
diff --git a/src/pages/RecentUpdatesPage.tsx b/src/pages/RecentUpdatesPage.tsx index 0eea184..bd5942b 100644 --- a/src/pages/RecentUpdatesPage.tsx +++ b/src/pages/RecentUpdatesPage.tsx @@ -3,7 +3,7 @@ import { TrendingUp, Filter, Eye } from "lucide-react"; import styles from "../styles/RecentUpdatesPage.module.css"; import { getDocuments, openPdf, type ApiDocument } from "../services/theapi"; -const ITEMS_PER_PAGE = 8; +const DEFAULT_ITEMS_PER_PAGE = 8; export default function RecentUpdatesPage() { const [updates, setUpdates] = useState([]); @@ -11,6 +11,7 @@ export default function RecentUpdatesPage() { const [sourceFilter, setSourceFilter] = useState("All Sources"); const [typeFilter, setTypeFilter] = useState("All Types"); const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE); useEffect(() => { getDocuments() @@ -48,14 +49,26 @@ export default function RecentUpdatesPage() { ); }, [updates, sourceFilter, typeFilter]); - const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); + const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage)); const safePage = Math.min(currentPage, totalPages); const paginated = filtered.slice( - (safePage - 1) * ITEMS_PER_PAGE, - safePage * ITEMS_PER_PAGE + (safePage - 1) * itemsPerPage, + safePage * itemsPerPage ); + useEffect(() => { + const apply = () => { + const w = window.innerWidth; + if (w < 640) setItemsPerPage(3); + else if (w < 900) setItemsPerPage(6); + else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE); + }; + apply(); + window.addEventListener('resize', apply); + return () => window.removeEventListener('resize', apply); + }, []); + if (loading) { return (
@@ -200,21 +213,39 @@ export default function RecentUpdatesPage() { Previous - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (page) => ( - - ) - )} + {(() => { + const maxShow = 5; + const pages: (number | string)[] = []; + if (totalPages <= maxShow) { + for (let p = 1; p <= totalPages; p++) pages.push(p); + } else { + if (safePage <= 3) { + pages.push(1,2,3,4,'...', totalPages); + } else if (safePage >= totalPages - 2) { + pages.push(1,'...', totalPages-3, totalPages-2, totalPages-1, totalPages); + } else { + pages.push(1,'...', safePage-1, safePage, safePage+1,'...', totalPages); + } + } + + return pages.map((p, idx) => { + if (p === '...') return ; + const page = p as number; + return ( + + ); + }); + })()}