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
+9
View File
@@ -22,3 +22,12 @@ dist-ssr
*.njsproj
*.sln
*.sw?
@"
node_modules/
dist/
.env
.env.local
.DS_Store
*.log
"@
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RegWatch</title>
<title>frontend</title>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -20,6 +20,7 @@
}
@media (max-width: 768px) {
.contentWrapper{flex-direction:column}
.main {
margin-left: 0;
}
+11 -1
View File
@@ -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 (
<div className={styles.app}>
<Toasts />
<div className={styles.contentWrapper}>
<Sidebar
activeNav={activeNav}
@@ -161,12 +169,14 @@ export default function App() {
<Route path="/contact" element={<ContactPage />} />
<Route path="/cgu" element={<CGU />} />
<Route path="/mentions-legales" element={<MentionsLegales />} />
<Route path="/admin" element={<AdminRoute user={authState.user}><Admin /></AdminRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</main>
</div>
<Footer onNavClick={handleNavClick} />
<CookieBanner />
<BackToTop />
</div>
);
}
+10
View File
@@ -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 <Navigate to='/' replace />
}
+15
View File
@@ -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 (
<button className={css.btn} onClick={()=>window.scrollTo({top:0,behavior:'smooth'})} aria-label="Back to top"></button>
)
}
+44 -47
View File
@@ -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 (
<div style={{
position: "fixed",
bottom: "1rem",
left: "1rem",
right: "1rem",
zIndex: 9999,
background: "rgba(15, 23, 42, 0.95)",
color: "#f8fafc",
padding: "1rem 1.25rem",
borderRadius: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem"
}} role="dialog">
<p style={{ flex: 1, margin: 0 }}>
Ce site utilise des cookies pour améliorer votre expérience utilisateur.
</p>
<button
onClick={handleAccept}
style={{
background: "#22c55e",
color: "white",
border: "none",
borderRadius: "9999px",
padding: "0.75rem 1.25rem",
fontWeight: 600,
cursor: "pointer"
}}
>
Accepter
</button>
<div className={styles.banner} role="dialog" aria-live="polite">
<div className={styles.inner}>
<div className={styles.text}>
Ce site utilise des cookies pour améliorer votre expérience. En
continuant, vous acceptez l'utilisation des cookies.
</div>
<div className={styles.controls}>
<button className={styles.accept} onClick={accept}>
Accepter
</button>
<button className={styles.decline} onClick={decline}>
Refuser
</button>
<a className={styles.link} href="/cgu">
En savoir plus
</a>
</div>
</div>
</div>
);
)
}
+21 -5
View File
@@ -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: <Clock size={18} /> },
];
if (user?.role === 'admin') {
navItems.push({ key: 'admin', label: 'Administration', icon: <Settings size={18} /> })
}
const initials = user
? (user.full_name || user.email)
.split(/[ .@_-]+/)
@@ -52,7 +60,13 @@ export default function Sidebar({
: '';
return (
<aside className={styles.sidebar}>
<>
<button className={styles.mobileToggle} aria-label="Open menu" onClick={()=>setOpen(true)}></button>
{open && <div className={styles.overlay} onClick={()=>setOpen(false)} />}
<aside className={`${styles.sidebar} ${open?styles.open:''}`}>
<button className={styles.mobileClose} onClick={()=>setOpen(false)} aria-label="Close menu"></button>
<div className={styles.header}>
<h1 className={styles.title}>RegWatch MedLabs</h1>
<p className={styles.subtitle}>Regulatory Monitoring</p>
@@ -62,7 +76,7 @@ export default function Sidebar({
{navItems.map((item) => (
<button
key={item.key}
onClick={() => onNavClick(item.key)}
onClick={() => { onNavClick(item.key); setOpen(false) }}
className={`${styles.navButton} ${
activeNav === item.key
? styles.navButtonActive
@@ -92,6 +106,7 @@ export default function Sidebar({
>
{initials}
</div>
{user.role === 'admin' && <div className={styles.adminBadge}>ADMIN</div>}
</div>
)}
@@ -101,6 +116,7 @@ export default function Sidebar({
Logout
</button>
)}
</aside>
</aside>
</>
);
}
+36
View File
@@ -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<ToastItem[]>([])
useEffect(()=>{
const handler = (ev: Event) => {
const detail = (ev as CustomEvent<Record<string, unknown>>).detail || {}
const d = detail as Record<string, unknown>
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 (
<div className={css.container} aria-live="polite">
{toasts.map(t=> (
<div key={t.id} className={`${css.toast} ${t.type=== 'error'?css.error:(t.type==='success'?css.success:css.info)}`}>
{t.message}
</div>
))}
</div>
)
}
+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}
+1 -2
View File
@@ -1,5 +1,4 @@
// Injecté au build par Vite (VITE_API_URL). Repli sur le backend local en dev.
export const API_URL = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";
export const API_URL = "http://127.0.0.1:8000";
export type ApiDocument = {
id: number;
+58
View File
@@ -0,0 +1,58 @@
.page{padding:20px;max-width:1200px;margin:0 auto}
.header{display:flex;align-items:center;justify-content:space-between;gap:16px}
.tabs{display:flex;gap:8px}
.tabs button{background:transparent;border:1px solid #e6e9ef;padding:8px 12px;border-radius:8px;cursor:pointer}
.tabs .active{background:#eef2ff;border-color:#c7b3ff}
.content{margin-top:16px}
.spinner{padding:12px;background:#f8fafc;border:1px solid #e6eef6;border-radius:8px}
.error{padding:12px;background:#fff1f2;border:1px solid #fecaca;color:#b91c1c;border-radius:8px}
.panelHeader{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:12px}
.search{padding:8px;border:1px solid #e6eef6;border-radius:6px}
.tableWrap{overflow:auto;background:#fff;border:1px solid #eef2f7;padding:8px;border-radius:8px}
.table{width:100%;border-collapse:collapse}
.table th,.table td{padding:10px;border-bottom:1px solid #f1f5f9;text-align:left}
.rowActions{display:flex;gap:8px}
.danger{background:#ef4444;color:#fff;border:none;padding:8px 10px;border-radius:6px;cursor:pointer}
.badgeAdmin{background:#7c3aed;color:#fff;padding:4px 8px;border-radius:999px;font-weight:600}
.badgeUser{background:#94a3b8;color:#fff;padding:4px 8px;border-radius:999px;font-weight:600}
.badgeCIR{background:#2563eb;color:#fff;padding:4px 8px;border-radius:6px}
.badgeSCCS{background:#10b981;color:#fff;padding:4px 8px;border-radius:6px}
.empty{padding:12px;color:#64748b}
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}
.card{background:#fff;padding:16px;border-radius:8px;display:flex;flex-direction:column;gap:8px;align-items:flex-start}
.cardLabel{font-size:14px;color:#64748b}
.cardValue{font-size:20px;font-weight:700}
.chartWrap{background:#fff;padding:12px;border-radius:8px}
.bars{display:flex;flex-direction:column;gap:8px}
.barRow{display:flex;align-items:center;gap:8px}
.barTitle{width:60px}
.barFill{height:14px;background:#2563eb;border-radius:6px;flex:1}
.barFillAlt{background:#10b981}
.barVal{width:40px;text-align:right}
.scraperControls{display:flex;gap:20px;align-items:flex-start}
.logList{background:#fff;padding:12px;border-radius:8px}
.status{font-weight:700}
.modalBackdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(2,6,23,0.45);display:flex;align-items:center;justify-content:center;z-index:9999}
.modal{background:#fff;padding:20px;border-radius:8px;min-width:320px}
.modalActions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
@media (max-width: 768px){
.cards{grid-template-columns:repeat(2,1fr)}
.table th, .table td{display:block}
.table tr{margin-bottom:12px;border-bottom:none}
.tableWrap{padding:0}
}
@media (max-width: 480px){
.cards{grid-template-columns:1fr}
.scraperControls{flex-direction:column}
.tabs{flex-wrap:wrap}
}
+1
View File
@@ -0,0 +1 @@
.btn{position:fixed;right:20px;bottom:20px;background:#1e293b;color:#fff;border:none;border-radius:999px;width:44px;height:44px;font-size:20px;cursor:pointer;box-shadow:0 8px 20px rgba(2,6,23,0.2);z-index:12000}
+64 -53
View File
@@ -1,69 +1,80 @@
.banner {
position: fixed;
bottom: 1rem;
left: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
justify-content: space-between;
background: rgba(15, 23, 42, 0.95);
color: #f8fafc;
padding: 1rem 1.25rem;
border-radius: 1rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
backdrop-filter: blur(10px);
.banner{
position:fixed;
left:0;
right:0;
bottom:0;
z-index:9999;
background:#ffffff;
color:#1e293b;
box-shadow:0 -2px 12px rgba(2,6,23,0.08);
}
.bannerText {
flex: 1 1 280px;
font-size: 0.95rem;
line-height: 1.5;
.inner{
max-width:1100px;
margin:0 auto;
padding:16px 20px;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-end;
.text{
flex:1 1 60%;
font-size:14px;
line-height:1.4;
}
.button {
background: #22c55e;
color: white;
border: none;
border-radius: 9999px;
padding: 0.75rem 1.25rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
.controls{
display:flex;
gap:8px;
align-items:center;
}
.button:hover {
background: #16a34a;
.accept{
background:#1e293b;
color:#fff;
border:none;
padding:10px 16px;
border-radius:8px;
cursor:pointer;
font-weight:600;
}
.link {
color: #f8fafc;
text-decoration: underline;
font-weight: 500;
.decline{
background:transparent;
color:#1e293b;
border:1px solid rgba(30,41,59,0.12);
padding:10px 14px;
border-radius:8px;
cursor:pointer;
}
@media (max-width: 640px) {
.banner {
flex-direction: column;
align-items: stretch;
.link{
color:#1e293b;
text-decoration:underline;
margin-left:6px;
font-size:14px;
}
@media (max-width: 600px){
.inner{
flex-direction:column;
align-items:stretch;
}
.actions {
justify-content: stretch;
.text{
margin-bottom:8px;
}
.button,
.link {
width: 100%;
text-align: center;
.controls{
flex-direction:column;
}
.accept, .decline{
width:100%;
}
.link{
display:block;
text-align:center;
width:100%;
padding:8px 0;
}
}
+12 -1
View File
@@ -203,7 +203,7 @@
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
color: rgb(17, 24, 39);
}
.cellWhitespace {
@@ -243,6 +243,8 @@
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
color: rgb(55, 65, 81);
.ellipsis{ display:inline-flex; align-items:center; padding:0 8px; color: rgb(107,114,128); }
background-color: white;
cursor: pointer;
transition: all 0.2s;
@@ -282,3 +284,12 @@
.pageNumberInactive:hover {
background-color: rgb(249, 250, 251);
}
/* Mobile: make each table row a card */
@media (max-width: 640px) {
.tableHeader { display: none; }
.tableRow { display: block; padding: 12px; border-bottom: 1px solid rgb(229,231,235); }
.titleCell { font-weight:700; font-size:1rem; display:flex; align-items:center; gap:8px }
.tableRow span { display:block; margin-top:6px; color: rgb(17,24,39) }
.openButton { width:100%; display:block }
}
+52 -4
View File
@@ -11,6 +11,21 @@
z-index: 10;
}
.mobileToggle{
display:none;
position:fixed;
top:12px;
left:12px;
z-index:11000;
background:transparent;
border:none;
font-size:20px;
cursor:pointer;
}
/* hide desktop close icon */
.mobileClose{ display:none }
.header {
padding: 1.5rem 1.25rem;
border-bottom: 1px solid rgb(229, 231, 235);
@@ -109,6 +124,16 @@
border-color: rgb(209, 213, 219);
}
.adminBadge{
background:#7c3aed;
color:#fff;
padding:4px 8px;
border-radius:999px;
font-weight:700;
margin-left:8px;
align-self:center;
}
/* ===== LOGOUT ===== */
.logoutBtn {
@@ -141,10 +166,33 @@
}
@media (max-width: 768px) {
.mobileToggle{display:block}
.mobileToggle{ color: rgb(17,24,39); background: white; padding:6px 8px; border-radius:8px; border:1px solid rgb(229,231,235) }
.sidebar {
width: 100%;
position: relative;
height: auto;
margin-bottom: 1rem;
position:fixed;
top:0;
left:0;
height:100%;
width:80%;
max-width:360px;
transform:translateX(-110%);
transition:transform 240ms ease;
box-shadow: 8px 0 24px rgba(2,6,23,0.15);
z-index:11001;
background:white;
}
.sidebar.open{
transform:translateX(0);
}
.overlay{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(2,6,23,0.45);z-index:11000}
.mobileClose{position:absolute;top:10px;right:10px;border:none;background:transparent;font-size:20px;cursor:pointer;display:block}
.open{transform:translateX(0)}
/* overlay will be handled by a simple full-screen element appended in DOM via JS if needed */
}
+5
View File
@@ -0,0 +1,5 @@
.container{position:fixed;left:16px;bottom:24px;display:flex;flex-direction:column-reverse;gap:8px;z-index:12000}
.toast{padding:10px 14px;border-radius:8px;color:#fff;box-shadow:0 6px 18px rgba(2,6,23,0.12);min-width:200px}
.info{background:#334155}
.success{background:#059669}
.error{background:#ef4444}
+4
View File
@@ -4,6 +4,10 @@ body {
background: #f4f6f8;
}
/* Global required rules */
* { box-sizing: border-box; }
img, table { max-width: 100%; }
.layout {
display: flex;
}