Admin : page admin + responsive + CookieBanner + Toast + BackToTop
Build & Deploy / build (push) Successful in 32s

This commit is contained in:
Mouignihazi
2026-08-15 15:22:29 +02:00
parent 95b39bd494
commit 2197aad4e9
22 changed files with 821 additions and 185 deletions
+296
View File
@@ -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<User[]>([])
const [docs, setDocs] = useState<Document[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [logs, setLogs] = useState<ScraperLog[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmAction, setConfirmAction] = useState<() => void>(()=>{})
const [confirmText, setConfirmText] = useState('')
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<string | null>(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<string, unknown>
const rb = b as unknown as Record<string, unknown>
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<string, unknown>
const rb = b as unknown as Record<string, unknown>
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 (
<div className={styles.page}>
<header className={styles.header}>
<h1>Administration</h1>
<nav className={styles.tabs}>
<button className={tab==='users'?styles.active:''} onClick={()=>setTab('users')}>Utilisateurs</button>
<button className={tab==='documents'?styles.active:''} onClick={()=>setTab('documents')}>Documents</button>
<button className={tab==='stats'?styles.active:''} onClick={()=>setTab('stats')}>Statistiques</button>
<button className={tab==='scrapers'?styles.active:''} onClick={()=>setTab('scrapers')}>Scrapers</button>
</nav>
</header>
<main className={styles.content}>
{loading && <div className={styles.spinner}>Chargement...</div>}
{error && <div className={styles.error}>{error}</div>}
{tab==='users' && (
<section>
<div className={styles.panelHeader}>
<div>{users.length} utilisateurs au total</div>
</div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th onClick={()=>{ setSortKey('id'); setSortDir(sortDir==='asc'?'desc':'asc') }}>ID</th>
<th onClick={()=>{ setSortKey('email'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Email</th>
<th onClick={()=>{ setSortKey('full_name'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Nom complet</th>
<th onClick={()=>{ setSortKey('role'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Rôle</th>
<th></th>
</tr>
</thead>
<tbody>
{sortedUsers.map(u=> (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.full_name||'-'}</td>
<td><span className={u.role==='admin'?styles.badgeAdmin:styles.badgeUser}>{u.role}</span></td>
<td className={styles.rowActions}>
<button className={styles.danger} onClick={()=>openConfirm('Supprimer cet utilisateur ?', ()=>deleteUser(u.id))}>Supprimer</button>
<button onClick={()=>openConfirm(u.role==='admin'? 'Rétrograder cet utilisateur ?' : 'Passer admin ?', ()=>toggleRole(u))}>{u.role==='admin'?'Rétrograder':'Passer admin'}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{tab==='documents' && (
<section>
<div className={styles.panelHeader}>
<div>{docs.length} documents au total</div>
<input className={styles.search} placeholder="Rechercher..." value={search} onChange={e=>setSearch(e.target.value)} />
</div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th onClick={()=>{ setSortKey('id'); setSortDir(sortDir==='asc'?'desc':'asc') }}>ID</th>
<th onClick={()=>{ setSortKey('title'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Title</th>
<th onClick={()=>{ setSortKey('source'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Source</th>
<th onClick={()=>{ setSortKey('ingredient'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Ingredient</th>
<th onClick={()=>{ setSortKey('date'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{sortedDocs.map(d=> (
<tr key={d.id}>
<td>{d.id}</td>
<td>{d.title}</td>
<td><span className={d.source==='CIR'?styles.badgeCIR:styles.badgeSCCS}>{d.source}</span></td>
<td>{d.ingredient||'-'}</td>
<td>{d.date||'-'}</td>
<td className={styles.rowActions}><button className={styles.danger} onClick={()=>openConfirm('Supprimer ce document ?', ()=>deleteDocument(d.id))}>Supprimer</button></td>
</tr>
))}
</tbody>
</table>
{filteredDocs.length===0 && <div className={styles.empty}>Aucun document trouvé.</div>}
</div>
</section>
)}
{tab==='stats' && (
<section>
<div className={styles.cards}>
<div className={styles.card}><FileText/><div className={styles.cardLabel}>Total documents</div><div className={styles.cardValue}>{stats?.total_documents ?? '-'}</div></div>
<div className={styles.card}><Database/><div className={styles.cardLabel}>Documents CIR</div><div className={styles.cardValue}>{stats?.cir ?? '-'}</div></div>
<div className={styles.card}><Database/><div className={styles.cardLabel}>Documents SCCS</div><div className={styles.cardValue}>{stats?.sccs ?? '-'}</div></div>
<div className={styles.card}><Users/><div className={styles.cardLabel}>Total utilisateurs</div><div className={styles.cardValue}>{stats?.users ?? '-'}</div></div>
</div>
<div className={styles.chartWrap}>
<div className={styles.chartLabel}>CIR vs SCCS</div>
<div className={styles.bars}>
<div className={styles.barRow}><div className={styles.barTitle}>CIR</div><div className={styles.barFill} style={{width:`${(stats?.cir||0)/(Math.max(1, (stats?.cir||0)+(stats?.sccs||0))) * 100}%`}}></div><div className={styles.barVal}>{stats?.cir||0}</div></div>
<div className={styles.barRow}><div className={styles.barTitle}>SCCS</div><div className={`${styles.barFill} ${styles.barFillAlt}`} style={{width:`${(stats?.sccs||0)/(Math.max(1, (stats?.cir||0)+(stats?.sccs||0))) * 100}%`}}></div><div className={styles.barVal}>{stats?.sccs||0}</div></div>
</div>
</div>
</section>
)}
{tab==='scrapers' && (
<section>
<div className={styles.panelHeader}>
<div>Scrapers</div>
</div>
<div className={styles.scraperControls}>
<div>
<button onClick={()=>runScraper('CIR')}>Lancer scraper CIR</button>
<button onClick={()=>runScraper('SCCS')}>Lancer scraper SCCS</button>
</div>
<div className={styles.logList}>
<h4>Dernières exécutions</h4>
<ul>
{logs.map(l=> (
<li key={l.id}><strong>{l.when}</strong> {l.source} <span className={styles.status}>{l.status}</span></li>
))}
{logs.length===0 && <li>Aucune exécution.</li>}
</ul>
</div>
</div>
</section>
)}
</main>
{confirmOpen && (
<div className={styles.modalBackdrop}>
<div className={styles.modal}>
<p>{confirmText}</p>
<div className={styles.modalActions}>
<button onClick={()=>{setConfirmOpen(false)}}>Annuler</button>
<button className={styles.danger} onClick={()=>{ setConfirmOpen(false); confirmAction(); }}>Confirmer</button>
</div>
</div>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -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();
+52 -19
View File
@@ -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<ApiDocument[]>([]);
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 (
<div className={styles.container}>
@@ -246,21 +260,40 @@ export default function DocumentsPage() {
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
)
)}
{/** 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 <span key={`dot-${idx}`} className={styles.ellipsis}></span>;
const page = p as number;
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
);
});
})()}
<button
onClick={() =>
+77 -32
View File
@@ -11,6 +11,8 @@ export default function IngredientSearchPage() {
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
// FETCH API
useEffect(() => {
@@ -23,6 +25,19 @@ export default function IngredientSearchPage() {
.finally(() => setLoading(false));
}, []);
// responsive items per page
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(8);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
@@ -117,42 +132,72 @@ export default function IngredientSearchPage() {
<p className={styles.emptyMessage}>No documents found.</p>
</div>
) : (
filtered.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
(() => {
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) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
</div>
))}
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
{totalPages > 1 && (
<div className={styles.pagination}>
<button className={styles.paginationButton} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={currentPage===1}>Previous</button>
{(() => {
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 === '...' ? <span key={`dot-${idx}`} className={styles.ellipsis}></span> : (
<button key={p} onClick={() => setCurrentPage(Number(p))} className={Number(p)===currentPage ? styles.pageNumberActive : styles.pageNumberInactive}>{p}</button>
));
})()}
<button className={styles.paginationButton} onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage===totalPages}>Next</button>
</div>
)}
</div>
</div>
))
</>
);
})()
)}
</div>
</div>
+50 -19
View File
@@ -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<ApiDocument[]>([]);
@@ -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 (
<div className={styles.container}>
@@ -200,21 +213,39 @@ export default function RecentUpdatesPage() {
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
)
)}
{(() => {
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 <span key={`dot-${idx}`} className={styles.ellipsis}></span>;
const page = p as number;
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
);
});
})()}
<button
className={styles.paginationButton}