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:
@@ -22,3 +22,12 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
@"
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
"@
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>RegWatch</title>
|
<title>frontend</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.contentWrapper{flex-direction:column}
|
||||||
.main {
|
.main {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -10,10 +10,14 @@ import AboutPage from "./pages/AboutPage";
|
|||||||
import ContactPage from "./pages/ContactPage";
|
import ContactPage from "./pages/ContactPage";
|
||||||
import CGU from "./pages/CGU";
|
import CGU from "./pages/CGU";
|
||||||
import MentionsLegales from "./pages/MentionsLegales";
|
import MentionsLegales from "./pages/MentionsLegales";
|
||||||
|
import Admin from "./pages/Admin";
|
||||||
|
import AdminRoute from "./components/AdminRoute";
|
||||||
|
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "./components/Footer";
|
||||||
import CookieBanner from "./components/CookieBanner";
|
import CookieBanner from "./components/CookieBanner";
|
||||||
|
import Toasts from "./components/Toast";
|
||||||
|
import BackToTop from "./components/BackToTop";
|
||||||
|
|
||||||
import styles from "./App.module.css";
|
import styles from "./App.module.css";
|
||||||
|
|
||||||
@@ -27,7 +31,8 @@ type NavItem =
|
|||||||
| "about"
|
| "about"
|
||||||
| "contact"
|
| "contact"
|
||||||
| "cgu"
|
| "cgu"
|
||||||
| "mentions-legales";
|
| "mentions-legales"
|
||||||
|
| "admin";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -96,6 +101,7 @@ export default function App() {
|
|||||||
: location.pathname === "/contact" ? "contact"
|
: location.pathname === "/contact" ? "contact"
|
||||||
: location.pathname === "/cgu" ? "cgu"
|
: location.pathname === "/cgu" ? "cgu"
|
||||||
: location.pathname === "/mentions-legales"? "mentions-legales"
|
: location.pathname === "/mentions-legales"? "mentions-legales"
|
||||||
|
: location.pathname === "/admin"? "admin"
|
||||||
: "home";
|
: "home";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,6 +126,7 @@ export default function App() {
|
|||||||
: item === "about" ? "/about"
|
: item === "about" ? "/about"
|
||||||
: item === "contact" ? "/contact"
|
: item === "contact" ? "/contact"
|
||||||
: item === "cgu" ? "/cgu"
|
: item === "cgu" ? "/cgu"
|
||||||
|
: item === 'admin' ? '/admin'
|
||||||
: "/mentions-legales";
|
: "/mentions-legales";
|
||||||
navigate(path);
|
navigate(path);
|
||||||
};
|
};
|
||||||
@@ -144,6 +151,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
|
<Toasts />
|
||||||
<div className={styles.contentWrapper}>
|
<div className={styles.contentWrapper}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeNav={activeNav}
|
activeNav={activeNav}
|
||||||
@@ -161,12 +169,14 @@ export default function App() {
|
|||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/cgu" element={<CGU />} />
|
<Route path="/cgu" element={<CGU />} />
|
||||||
<Route path="/mentions-legales" element={<MentionsLegales />} />
|
<Route path="/mentions-legales" element={<MentionsLegales />} />
|
||||||
|
<Route path="/admin" element={<AdminRoute user={authState.user}><Admin /></AdminRoute>} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Footer onNavClick={handleNavClick} />
|
<Footer onNavClick={handleNavClick} />
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
|
<BackToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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() {
|
export default function CookieBanner() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(() => {
|
||||||
|
try {
|
||||||
useEffect(() => {
|
const val = localStorage.getItem(STORAGE_KEY)
|
||||||
const consent = localStorage.getItem(KEY);
|
return val !== 'true'
|
||||||
if (!consent) {
|
} catch {
|
||||||
setVisible(true);
|
return true
|
||||||
}
|
}
|
||||||
}, [setVisible]);
|
})
|
||||||
|
|
||||||
const handleAccept = () => {
|
const accept = () => {
|
||||||
localStorage.setItem(KEY, "true");
|
try {
|
||||||
setVisible(false);
|
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 (
|
return (
|
||||||
<div style={{
|
<div className={styles.banner} role="dialog" aria-live="polite">
|
||||||
position: "fixed",
|
<div className={styles.inner}>
|
||||||
bottom: "1rem",
|
<div className={styles.text}>
|
||||||
left: "1rem",
|
Ce site utilise des cookies pour améliorer votre expérience. En
|
||||||
right: "1rem",
|
continuant, vous acceptez l'utilisation des cookies.
|
||||||
zIndex: 9999,
|
</div>
|
||||||
background: "rgba(15, 23, 42, 0.95)",
|
|
||||||
color: "#f8fafc",
|
<div className={styles.controls}>
|
||||||
padding: "1rem 1.25rem",
|
<button className={styles.accept} onClick={accept}>
|
||||||
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
|
Accepter
|
||||||
</button>
|
</button>
|
||||||
|
<button className={styles.decline} onClick={decline}>
|
||||||
|
Refuser
|
||||||
|
</button>
|
||||||
|
<a className={styles.link} href="/cgu">
|
||||||
|
En savoir plus
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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';
|
import styles from '../styles/Sidebar.module.css';
|
||||||
|
|
||||||
type NavItem =
|
type NavItem =
|
||||||
@@ -9,12 +10,14 @@ type NavItem =
|
|||||||
| 'about'
|
| 'about'
|
||||||
| 'contact'
|
| 'contact'
|
||||||
| 'cgu'
|
| 'cgu'
|
||||||
| 'mentions-legales';
|
| 'mentions-legales'
|
||||||
|
| 'admin';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -30,6 +33,7 @@ export default function Sidebar({
|
|||||||
onLogout,
|
onLogout,
|
||||||
user,
|
user,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
const navItems: {
|
const navItems: {
|
||||||
key: NavItem;
|
key: NavItem;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -41,6 +45,10 @@ export default function Sidebar({
|
|||||||
{ key: 'recent-updates', label: 'Recent Updates', icon: <Clock size={18} /> },
|
{ 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
|
const initials = user
|
||||||
? (user.full_name || user.email)
|
? (user.full_name || user.email)
|
||||||
.split(/[ .@_-]+/)
|
.split(/[ .@_-]+/)
|
||||||
@@ -52,7 +60,13 @@ export default function Sidebar({
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
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}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.title}>RegWatch MedLabs</h1>
|
<h1 className={styles.title}>RegWatch MedLabs</h1>
|
||||||
<p className={styles.subtitle}>Regulatory Monitoring</p>
|
<p className={styles.subtitle}>Regulatory Monitoring</p>
|
||||||
@@ -62,7 +76,7 @@ export default function Sidebar({
|
|||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => onNavClick(item.key)}
|
onClick={() => { onNavClick(item.key); setOpen(false) }}
|
||||||
className={`${styles.navButton} ${
|
className={`${styles.navButton} ${
|
||||||
activeNav === item.key
|
activeNav === item.key
|
||||||
? styles.navButtonActive
|
? styles.navButtonActive
|
||||||
@@ -92,6 +106,7 @@ export default function Sidebar({
|
|||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
{user.role === 'admin' && <div className={styles.adminBadge}>ADMIN</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -102,5 +117,6 @@ export default function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+41
-8
@@ -18,11 +18,12 @@ type ApiDocument = {
|
|||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 8;
|
const DEFAULT_ITEMS_PER_PAGE = 8;
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [documents, setDocuments] = useState<ApiDocument[]>([]);
|
const [documents, setDocuments] = useState<ApiDocument[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const [sourceFilter, setSourceFilter] = useState("All Sources");
|
const [sourceFilter, setSourceFilter] = useState("All Sources");
|
||||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||||
@@ -94,16 +95,29 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
const totalPages = Math.max(
|
const totalPages = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(filtered.length / ITEMS_PER_PAGE)
|
Math.ceil(filtered.length / itemsPerPage)
|
||||||
);
|
);
|
||||||
|
|
||||||
const safePage = Math.min(currentPage, totalPages);
|
const safePage = Math.min(currentPage, totalPages);
|
||||||
|
|
||||||
const paginated = filtered.slice(
|
const paginated = filtered.slice(
|
||||||
(safePage - 1) * ITEMS_PER_PAGE,
|
(safePage - 1) * itemsPerPage,
|
||||||
safePage * ITEMS_PER_PAGE
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -246,8 +260,26 @@ export default function DocumentsPage() {
|
|||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
{/** Render a limited set of page numbers for readability */}
|
||||||
(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 <span key={`dot-${idx}`} className={styles.ellipsis}>…</span>;
|
||||||
|
const page = p as number;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
onClick={() => setCurrentPage(page)}
|
onClick={() => setCurrentPage(page)}
|
||||||
@@ -259,8 +291,9 @@ export default function DocumentsPage() {
|
|||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
)}
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default function IngredientSearchPage() {
|
|||||||
const [sourceFilter, setSourceFilter] = useState("All Sources");
|
const [sourceFilter, setSourceFilter] = useState("All Sources");
|
||||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||||
const [dateFilter, setDateFilter] = useState("All Dates");
|
const [dateFilter, setDateFilter] = useState("All Dates");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(8);
|
||||||
|
|
||||||
// FETCH API
|
// FETCH API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,6 +25,19 @@ export default function IngredientSearchPage() {
|
|||||||
.finally(() => setLoading(false));
|
.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
|
// FILTER OPTIONS
|
||||||
const allSources = useMemo(() => [
|
const allSources = useMemo(() => [
|
||||||
"All Sources",
|
"All Sources",
|
||||||
@@ -117,7 +132,13 @@ export default function IngredientSearchPage() {
|
|||||||
<p className={styles.emptyMessage}>No documents found.</p>
|
<p className={styles.emptyMessage}>No documents found.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((doc) => (
|
(() => {
|
||||||
|
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 key={doc.id} className={styles.documentCard}>
|
||||||
<div className={styles.documentCardFlex}>
|
<div className={styles.documentCardFlex}>
|
||||||
<div className={styles.documentContent}>
|
<div className={styles.documentContent}>
|
||||||
@@ -152,7 +173,31 @@ export default function IngredientSearchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
|
|
||||||
|
{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 styles from "../styles/RecentUpdatesPage.module.css";
|
||||||
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
|
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 8;
|
const DEFAULT_ITEMS_PER_PAGE = 8;
|
||||||
|
|
||||||
export default function RecentUpdatesPage() {
|
export default function RecentUpdatesPage() {
|
||||||
const [updates, setUpdates] = useState<ApiDocument[]>([]);
|
const [updates, setUpdates] = useState<ApiDocument[]>([]);
|
||||||
@@ -11,6 +11,7 @@ export default function RecentUpdatesPage() {
|
|||||||
const [sourceFilter, setSourceFilter] = useState("All Sources");
|
const [sourceFilter, setSourceFilter] = useState("All Sources");
|
||||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getDocuments()
|
getDocuments()
|
||||||
@@ -48,14 +49,26 @@ export default function RecentUpdatesPage() {
|
|||||||
);
|
);
|
||||||
}, [updates, sourceFilter, typeFilter]);
|
}, [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 safePage = Math.min(currentPage, totalPages);
|
||||||
|
|
||||||
const paginated = filtered.slice(
|
const paginated = filtered.slice(
|
||||||
(safePage - 1) * ITEMS_PER_PAGE,
|
(safePage - 1) * itemsPerPage,
|
||||||
safePage * ITEMS_PER_PAGE
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -200,8 +213,25 @@ export default function RecentUpdatesPage() {
|
|||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{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 <span key={`dot-${idx}`} className={styles.ellipsis}>…</span>;
|
||||||
|
const page = p as number;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
onClick={() => setCurrentPage(page)}
|
onClick={() => setCurrentPage(page)}
|
||||||
@@ -213,8 +243,9 @@ export default function RecentUpdatesPage() {
|
|||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
)}
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.paginationButton}
|
className={styles.paginationButton}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Injecté au build par Vite (VITE_API_URL). Repli sur le backend local en dev.
|
export const API_URL = "http://127.0.0.1:8000";
|
||||||
export const API_URL = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";
|
|
||||||
|
|
||||||
export type ApiDocument = {
|
export type ApiDocument = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -1,69 +1,80 @@
|
|||||||
.banner {
|
.banner{
|
||||||
position: fixed;
|
position:fixed;
|
||||||
bottom: 1rem;
|
left:0;
|
||||||
left: 1rem;
|
right:0;
|
||||||
right: 1rem;
|
bottom:0;
|
||||||
z-index: 1000;
|
z-index:9999;
|
||||||
display: flex;
|
background:#ffffff;
|
||||||
flex-wrap: wrap;
|
color:#1e293b;
|
||||||
align-items: center;
|
box-shadow:0 -2px 12px rgba(2,6,23,0.08);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bannerText {
|
.inner{
|
||||||
flex: 1 1 280px;
|
max-width:1100px;
|
||||||
font-size: 0.95rem;
|
margin:0 auto;
|
||||||
line-height: 1.5;
|
padding:16px 20px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.text{
|
||||||
display: flex;
|
flex:1 1 60%;
|
||||||
flex-wrap: wrap;
|
font-size:14px;
|
||||||
gap: 0.75rem;
|
line-height:1.4;
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.controls{
|
||||||
background: #22c55e;
|
display:flex;
|
||||||
color: white;
|
gap:8px;
|
||||||
border: none;
|
align-items:center;
|
||||||
border-radius: 9999px;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.accept{
|
||||||
background: #16a34a;
|
background:#1e293b;
|
||||||
|
color:#fff;
|
||||||
|
border:none;
|
||||||
|
padding:10px 16px;
|
||||||
|
border-radius:8px;
|
||||||
|
cursor:pointer;
|
||||||
|
font-weight:600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.decline{
|
||||||
color: #f8fafc;
|
background:transparent;
|
||||||
text-decoration: underline;
|
color:#1e293b;
|
||||||
font-weight: 500;
|
border:1px solid rgba(30,41,59,0.12);
|
||||||
|
padding:10px 14px;
|
||||||
|
border-radius:8px;
|
||||||
|
cursor:pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.link{
|
||||||
.banner {
|
color:#1e293b;
|
||||||
flex-direction: column;
|
text-decoration:underline;
|
||||||
align-items: stretch;
|
margin-left:6px;
|
||||||
|
font-size:14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px){
|
||||||
|
.inner{
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:stretch;
|
||||||
}
|
}
|
||||||
|
.text{
|
||||||
.actions {
|
margin-bottom:8px;
|
||||||
justify-content: stretch;
|
|
||||||
}
|
}
|
||||||
|
.controls{
|
||||||
.button,
|
flex-direction:column;
|
||||||
.link {
|
}
|
||||||
width: 100%;
|
.accept, .decline{
|
||||||
text-align: center;
|
width:100%;
|
||||||
|
}
|
||||||
|
.link{
|
||||||
|
display:block;
|
||||||
|
text-align:center;
|
||||||
|
width:100%;
|
||||||
|
padding:8px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: rgb(75, 85, 99);
|
color: rgb(17, 24, 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cellWhitespace {
|
.cellWhitespace {
|
||||||
@@ -243,6 +243,8 @@
|
|||||||
border: 1px solid rgb(209, 213, 219);
|
border: 1px solid rgb(209, 213, 219);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
color: rgb(55, 65, 81);
|
color: rgb(55, 65, 81);
|
||||||
|
|
||||||
|
.ellipsis{ display:inline-flex; align-items:center; padding:0 8px; color: rgb(107,114,128); }
|
||||||
background-color: white;
|
background-color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -282,3 +284,12 @@
|
|||||||
.pageNumberInactive:hover {
|
.pageNumberInactive:hover {
|
||||||
background-color: rgb(249, 250, 251);
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,21 @@
|
|||||||
z-index: 10;
|
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 {
|
.header {
|
||||||
padding: 1.5rem 1.25rem;
|
padding: 1.5rem 1.25rem;
|
||||||
border-bottom: 1px solid rgb(229, 231, 235);
|
border-bottom: 1px solid rgb(229, 231, 235);
|
||||||
@@ -109,6 +124,16 @@
|
|||||||
border-color: rgb(209, 213, 219);
|
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 ===== */
|
/* ===== LOGOUT ===== */
|
||||||
|
|
||||||
.logoutBtn {
|
.logoutBtn {
|
||||||
@@ -141,10 +166,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.sidebar {
|
||||||
width: 100%;
|
position:fixed;
|
||||||
position: relative;
|
top:0;
|
||||||
height: auto;
|
left:0;
|
||||||
margin-bottom: 1rem;
|
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 */
|
||||||
}
|
}
|
||||||
@@ -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,6 +4,10 @@ body {
|
|||||||
background: #f4f6f8;
|
background: #f4f6f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global required rules */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
img, table { max-width: 100%; }
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user