Admin : page admin + responsive + CookieBanner + Toast + BackToTop
Build & Deploy / build (push) Successful in 32s
Build & Deploy / build (push) Successful in 32s
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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={() =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user