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 @@
-
-
- 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.
+
+
+
+
- );
+ )
}
\ 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
+
+
+
+
+
+ | { 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 |
+ |
+
+
+
+ {sortedUsers.map(u=> (
+
+ | {u.id} |
+ {u.email} |
+ {u.full_name||'-'} |
+ {u.role} |
+
+
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ {tab==='documents' && (
+
+
+
{docs.length} documents au total
+
setSearch(e.target.value)} />
+
+
+
+
+
+
+ | { 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 |
+ |
+
+
+
+ {sortedDocs.map(d=> (
+
+ | {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 ?? '-'}
+
+
+
+
+ )}
+
+ {tab==='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 (
+
+ );
+ });
+ })()}