Pagination : un seul composant partagé pour les trois pages
Build & Deploy / build (push) Successful in 26s

La logique était triplicée à l'identique dans Documents, Recent Updates et
Ingredient Search, mais chaque page portait son propre style — d'où trois
rendus différents :

- IngredientSearchPage.module.css ne contenait AUCUNE classe de pagination :
  styles.pagination et consorts valaient undefined, les boutons s'affichaient
  donc entièrement sans style. C'est l'écart que tu voyais.
- DocumentsPage : règle .paginationButton malformée, un .ellipsis imbriqué
  dedans par accident de copier-coller.
- RecentUpdates : ni .ellipsis ni dimensions sur les numéros de page.

Un composant components/Pagination.tsx porte désormais la fenêtre de numéros
(extrémités toujours atteignables) et styles/Pagination.module.css le style
unique, sur l'accent bleu #2563eb du site — celui des boutons primaires, 22
occurrences contre 11 pour le violet. Les classes locales devenues mortes sont
supprimées des trois feuilles pour qu'aucun style divergent ne subsiste.

Libellés Previous/Next conservés : les pages publiques sont en anglais.

Vérifié au navigateur : même classe de module, même fond actif rgb(37,99,235),
mêmes dimensions sur les trois pages ; aucun débordement de 320 à 1280px, la
pagination passant à la ligne sous 768px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 20:39:36 +02:00
co-authored by Claude Opus 5
parent 9d7dadfdac
commit f170da8997
7 changed files with 1324 additions and 1404 deletions
+71
View File
@@ -0,0 +1,71 @@
import styles from "../styles/Pagination.module.css";
const MAX_VISIBLE_PAGES = 5;
type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
};
type PageEntry = number | "ellipsis";
/** Fenêtre de numéros affichés : les extrémités restent toujours atteignables. */
function buildPageList(currentPage: number, totalPages: number): PageEntry[] {
if (totalPages <= MAX_VISIBLE_PAGES) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
if (currentPage <= 3) {
return [1, 2, 3, 4, "ellipsis", totalPages];
}
if (currentPage >= totalPages - 2) {
return [1, "ellipsis", totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
}
return [1, "ellipsis", currentPage - 1, currentPage, currentPage + 1, "ellipsis", totalPages];
}
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
return (
<nav className={styles.pagination} aria-label="Pagination">
<button
className={styles.navButton}
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Previous
</button>
{buildPageList(currentPage, totalPages).map((entry, index) =>
entry === "ellipsis" ? (
<span key={`ellipsis-${index}`} className={styles.ellipsis} aria-hidden="true">
</span>
) : (
<button
key={entry}
className={entry === currentPage ? styles.pageActive : styles.page}
aria-current={entry === currentPage ? "page" : undefined}
onClick={() => onPageChange(entry)}
>
{entry}
</button>
)
)}
<button
className={styles.navButton}
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
Next
</button>
</nav>
);
}
+258 -311
View File
@@ -1,312 +1,259 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Filter, Filter,
ArrowUpDown, ArrowUpDown,
File as FileIcon, File as FileIcon,
} from "lucide-react"; } from "lucide-react";
import styles from "../styles/DocumentsPage.module.css"; import styles from "../styles/DocumentsPage.module.css";
import { getDocuments } from "../services/theapi"; import Pagination from "../components/Pagination";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string; type ApiDocument = {
ingredient: string; title: string;
source: string; ingredient: string;
type: string; source: string;
date?: string | null; type: string;
pdf_url: string; date?: string | null;
bold?: boolean; pdf_url: string;
}; bold?: boolean;
};
const DEFAULT_ITEMS_PER_PAGE = 8;
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]); export default function DocumentsPage() {
const [loading, setLoading] = useState(true); const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE); 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"); const [sourceFilter, setSourceFilter] = useState("All Sources");
const [dateFilter, setDateFilter] = useState("All Dates"); const [typeFilter, setTypeFilter] = useState("All Types");
const [sortBy, setSortBy] = useState("Date (Newest)"); const [dateFilter, setDateFilter] = useState("All Dates");
const [currentPage, setCurrentPage] = useState(1); const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments() useEffect(() => {
.then((data) => { getDocuments()
setDocuments(Array.isArray(data) ? data : []); .then((data) => {
}) setDocuments(Array.isArray(data) ? data : []);
.catch((err) => { })
console.error("API Error:", err); .catch((err) => {
setDocuments([]); console.error("API Error:", err);
}) setDocuments([]);
.finally(() => setLoading(false)); })
}, []); .finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))], const allSources = useMemo(
[documents] () => ["All Sources", ...new Set(documents.map((d) => d.source))],
); [documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))], const allTypes = useMemo(
[documents] () => ["All Types", ...new Set(documents.map((d) => d.type))],
); [documents]
);
const allDates = useMemo(
() => [ const allDates = useMemo(
"All Dates", () => [
...new Set( "All Dates",
documents.map((d) => String(d.date ?? "").slice(0, 4)) ...new Set(
), documents.map((d) => String(d.date ?? "").slice(0, 4))
], ),
[documents] ],
); [documents]
);
const filtered = useMemo(() => {
return documents const filtered = useMemo(() => {
.filter((d) => { return documents
const sourceMatch = .filter((d) => {
sourceFilter === "All Sources" || d.source === sourceFilter; const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter; const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch = const safeDate = String(d.date ?? "");
dateFilter === "All Dates" || const dateMatch =
safeDate.startsWith(dateFilter); dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
}) return sourceMatch && typeMatch && dateMatch;
.sort((a, b) => { })
const dateA = String(a.date ?? ""); .sort((a, b) => {
const dateB = String(b.date ?? ""); const dateA = String(a.date ?? "");
const titleA = String(a.title ?? ""); const dateB = String(b.date ?? "");
const titleB = String(b.title ?? ""); const titleA = String(a.title ?? "");
const titleB = String(b.title ?? "");
if (sortBy === "Date (Newest)") return dateB.localeCompare(dateA);
if (sortBy === "Date (Oldest)") return dateA.localeCompare(dateB); if (sortBy === "Date (Newest)") return dateB.localeCompare(dateA);
if (sortBy === "Title (A-Z)") return titleA.localeCompare(titleB); if (sortBy === "Date (Oldest)") return dateA.localeCompare(dateB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA); if (sortBy === "Title (A-Z)") return titleA.localeCompare(titleB);
return 0; if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
}); return 0;
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]); });
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
const totalPages = Math.max(
1, const totalPages = Math.max(
Math.ceil(filtered.length / itemsPerPage) 1,
); Math.ceil(filtered.length / itemsPerPage)
);
const safePage = Math.min(currentPage, totalPages);
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * itemsPerPage, const paginated = filtered.slice(
safePage * itemsPerPage (safePage - 1) * itemsPerPage,
); safePage * itemsPerPage
);
// adjust items per page based on viewport width
useEffect(() => { // adjust items per page based on viewport width
const apply = () => { useEffect(() => {
const w = window.innerWidth; const apply = () => {
if (w < 640) setItemsPerPage(3); const w = window.innerWidth;
else if (w < 900) setItemsPerPage(6); if (w < 640) setItemsPerPage(3);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE); else if (w < 900) setItemsPerPage(6);
}; else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
apply(); };
window.addEventListener('resize', apply); apply();
return () => window.removeEventListener('resize', apply); window.addEventListener('resize', apply);
}, []); return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return ( if (loading) {
<div className={styles.container}> return (
<p>Loading documents...</p> <div className={styles.container}>
</div> <p>Loading documents...</p>
); </div>
} );
}
return (
<div className={styles.container}> return (
<div className={styles.maxWidth}> <div className={styles.container}>
{/* HEADER */} <div className={styles.maxWidth}>
<div className={styles.header}> {/* HEADER */}
<h2 className={styles.title}>Document Database</h2> <div className={styles.header}>
<p className={styles.description}> <h2 className={styles.title}>Document Database</h2>
Complete collection of regulatory monitoring documents <p className={styles.description}>
</p> Complete collection of regulatory monitoring documents
</div> </p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}> {/* FILTERS */}
<div className={styles.filterLabel}> <div className={styles.filtersBox}>
<Filter size={16} /> <div className={styles.filterLabel}>
<span>Filters:</span> <Filter size={16} />
</div> <span>Filters:</span>
</div>
<select
value={sourceFilter} <select
onChange={(e) => setSourceFilter(e.target.value)} value={sourceFilter}
className={styles.select} onChange={(e) => setSourceFilter(e.target.value)}
> className={styles.select}
{allSources.map((s) => ( >
<option key={s} value={s}> {allSources.map((s) => (
{s} <option key={s} value={s}>
</option> {s}
))} </option>
</select> ))}
</select>
<select
value={typeFilter} <select
onChange={(e) => setTypeFilter(e.target.value)} value={typeFilter}
className={styles.select} onChange={(e) => setTypeFilter(e.target.value)}
> className={styles.select}
{allTypes.map((t) => ( >
<option key={t} value={t}> {allTypes.map((t) => (
{t} <option key={t} value={t}>
</option> {t}
))} </option>
</select> ))}
</select>
<select
value={dateFilter} <select
onChange={(e) => setDateFilter(e.target.value)} value={dateFilter}
className={styles.select} onChange={(e) => setDateFilter(e.target.value)}
> className={styles.select}
{allDates.map((d) => ( >
<option key={d} value={d}> {allDates.map((d) => (
{d} <option key={d} value={d}>
</option> {d}
))} </option>
</select> ))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} /> <div className={styles.sortWrapper}>
<select <ArrowUpDown size={16} />
value={sortBy} <select
onChange={(e) => setSortBy(e.target.value)} value={sortBy}
className={styles.select} onChange={(e) => setSortBy(e.target.value)}
> className={styles.select}
<option value="Date (Newest)">Date (Newest)</option> >
<option value="Date (Oldest)">Date (Oldest)</option> <option value="Date (Newest)">Date (Newest)</option>
<option value="Title (A-Z)">Title (A-Z)</option> <option value="Date (Oldest)">Date (Oldest)</option>
<option value="Title (Z-A)">Title (Z-A)</option> <option value="Title (A-Z)">Title (A-Z)</option>
</select> <option value="Title (Z-A)">Title (Z-A)</option>
</div> </select>
</div> </div>
</div>
{/* STATS */}
<div className={styles.statsRow}> {/* STATS */}
<p className={styles.statsText}> <div className={styles.statsRow}>
Total Documents: <p className={styles.statsText}>
<span className={styles.statsNumber}> Total Documents:
{" "} <span className={styles.statsNumber}>
{filtered.length} {" "}
</span> {filtered.length}
</p> </span>
</div> </p>
</div>
{/* TABLE */}
<div className={styles.table}> {/* TABLE */}
<div className={styles.tableHeader}> <div className={styles.table}>
<span>Title</span> <div className={styles.tableHeader}>
<span>Ingredient</span> <span>Title</span>
<span>Source</span> <span>Ingredient</span>
<span>Type</span> <span>Source</span>
<span>Date</span> <span>Type</span>
<span>PDF</span> <span>Date</span>
</div> <span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div {paginated.map((doc, index) => (
key={`${doc.title}-${doc.source}-${doc.date ?? index}`} <div
className={styles.tableRow} key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
> className={styles.tableRow}
<div className={styles.titleCell}> >
<FileIcon size={16} /> <div className={styles.titleCell}>
<span>{doc.title}</span> <FileIcon size={16} />
</div> <span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span> <span>{doc.ingredient}</span>
<span>{doc.type}</span> <span>{doc.source}</span>
<span>{doc.date}</span> <span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a <div>
href={doc.pdf_url} <a
target="_blank" href={doc.pdf_url}
rel="noreferrer" target="_blank"
> rel="noreferrer"
<button className={styles.openButton}> >
Open <button className={styles.openButton}>
</button> Open
</a> </button>
</div> </a>
</div> </div>
))} </div>
</div> ))}
</div>
{/* PAGINATION */}
{totalPages > 1 && ( <Pagination
<div className={styles.pagination}> currentPage={safePage}
<button totalPages={totalPages}
onClick={() => onPageChange={setCurrentPage}
setCurrentPage(Math.max(1, safePage - 1)) />
} </div>
disabled={safePage === 1} </div>
className={styles.paginationButton} );
>
Previous
</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={() =>
setCurrentPage(Math.min(totalPages, safePage + 1))
}
disabled={safePage === totalPages}
className={styles.paginationButton}
>
Next
</button>
</div>
)}
</div>
</div>
);
} }
+191 -205
View File
@@ -1,206 +1,192 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react"; import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css"; import styles from "../styles/IngredientSearchPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi"; import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]); export default function IngredientSearchPage() {
const [loading, setLoading] = useState(true); const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources"); const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("All Types"); const [sourceFilter, setSourceFilter] = useState("All Sources");
const [dateFilter, setDateFilter] = useState("All Dates"); const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1); const [dateFilter, setDateFilter] = useState("All Dates");
const [itemsPerPage, setItemsPerPage] = useState(8); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
// FETCH API
useEffect(() => { // FETCH API
getDocuments() useEffect(() => {
.then((data) => setDocuments(Array.isArray(data) ? data : [])) getDocuments()
.catch((err) => { .then((data) => setDocuments(Array.isArray(data) ? data : []))
console.error("API ERROR:", err); .catch((err) => {
setDocuments([]); console.error("API ERROR:", err);
}) setDocuments([]);
.finally(() => setLoading(false)); })
}, []); .finally(() => setLoading(false));
}, []);
// responsive items per page
useEffect(() => { // responsive items per page
const apply = () => { useEffect(() => {
const w = window.innerWidth; const apply = () => {
if (w < 640) setItemsPerPage(3); const w = window.innerWidth;
else if (w < 900) setItemsPerPage(6); if (w < 640) setItemsPerPage(3);
else setItemsPerPage(8); else if (w < 900) setItemsPerPage(6);
}; else setItemsPerPage(8);
apply(); };
window.addEventListener('resize', apply); apply();
return () => window.removeEventListener('resize', apply); window.addEventListener('resize', apply);
}, []); return () => window.removeEventListener('resize', apply);
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [ // FILTER OPTIONS
"All Sources", const allSources = useMemo(() => [
...new Set(documents.map((d) => d.source)), "All Sources",
], [documents]); ...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types", const allTypes = useMemo(() => [
...new Set(documents.map((d) => d.type)), "All Types",
], [documents]); ...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates", const allDates = useMemo(() => [
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(), "All Dates",
], [documents]); ...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => { // FILTERED RESULTS
return documents const filtered = useMemo(() => {
.filter((d) => { return documents
const queryMatch = .filter((d) => {
searchQuery === "" || const queryMatch =
d.ingredient.toLowerCase().includes(searchQuery.toLowerCase()) || searchQuery === "" ||
d.title.toLowerCase().includes(searchQuery.toLowerCase()); d.ingredient.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.title.toLowerCase().includes(searchQuery.toLowerCase());
const sourceMatch = sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch = typeFilter === "All Types" || d.type === typeFilter; const sourceMatch = sourceFilter === "All Sources" || d.source === sourceFilter;
const safeDate = String(d.date ?? ""); const typeMatch = typeFilter === "All Types" || d.type === typeFilter;
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter); const safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
}) return queryMatch && sourceMatch && typeMatch && dateMatch;
.sort((a, b) => String(b.date ?? "").localeCompare(String(a.date ?? ""))); })
}, [documents, searchQuery, sourceFilter, typeFilter, dateFilter]); .sort((a, b) => String(b.date ?? "").localeCompare(String(a.date ?? "")));
}, [documents, searchQuery, sourceFilter, typeFilter, dateFilter]);
if (loading) {
return <div className={styles.container}><p>Loading...</p></div>; if (loading) {
} return <div className={styles.container}><p>Loading...</p></div>;
}
return (
<div className={styles.container}> return (
<div className={styles.maxWidth}> <div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}> {/* HEADER */}
<h1 className={styles.title}>Ingredient Search</h1> <div className={styles.header}>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p> <h1 className={styles.title}>Ingredient Search</h1>
</div> <p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}> {/* SEARCH */}
<div className={styles.searchBar}> <div className={styles.searchBarWrapper}>
<input <div className={styles.searchBar}>
className={styles.searchInput} <input
type="text" className={styles.searchInput}
placeholder="Search ingredient..." type="text"
value={searchQuery} placeholder="Search ingredient..."
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
/> onChange={(e) => setSearchQuery(e.target.value)}
<Search size={20} className={styles.searchIcon} /> />
</div> <Search size={20} className={styles.searchIcon} />
</div> </div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}> {/* FILTERS */}
<div className={styles.filterHeader}> <div className={styles.filtersBox}>
<Filter size={16} className={styles.filterIcon} /> <div className={styles.filterHeader}>
<span>Filters</span> <Filter size={16} className={styles.filterIcon} />
</div> <span>Filters</span>
<div className={styles.filterControls}> </div>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}> <div className={styles.filterControls}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)} <select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
</select> {allSources.map((s) => <option key={s} value={s}>{s}</option>)}
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}> </select>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)} <select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
</select> {allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}> </select>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)} <select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
</select> {allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</div> </select>
</div> </div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}> {/* RESULTS */}
<span className={styles.resultsCount}>{filtered.length}</span> results found <div className={styles.resultsInfo}>
</div> <span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}> {/* DOCUMENTS */}
{filtered.length === 0 ? ( <div className={styles.documentsList}>
<div className={styles.emptyState}> {filtered.length === 0 ? (
<p className={styles.emptyMessage}>No documents found.</p> <div className={styles.emptyState}>
</div> <p className={styles.emptyMessage}>No documents found.</p>
) : ( </div>
(() => { ) : (
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage)); (() => {
const safePage = Math.min(currentPage, totalPages); const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage); const safePage = Math.min(currentPage, totalPages);
return ( const paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
<> return (
{paginated.map((doc) => ( <>
<div key={doc.id} className={styles.documentCard}> {paginated.map((doc) => (
<div className={styles.documentCardFlex}> <div key={doc.id} className={styles.documentCard}>
<div className={styles.documentContent}> <div className={styles.documentCardFlex}>
<h3 className={styles.documentTitle}>{doc.title}</h3> <div className={styles.documentContent}>
<div className={styles.documentMeta}> <h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.metaItem}> <div className={styles.documentMeta}>
<span className={styles.metaLabel}>Ingredient</span> <div className={styles.metaItem}>
<span className={styles.metaValue}>{doc.ingredient}</span> <span className={styles.metaLabel}>Ingredient</span>
</div> <span className={styles.metaValue}>{doc.ingredient}</span>
<div className={styles.metaItem}> </div>
<span className={styles.metaLabel}>Source</span> <div className={styles.metaItem}>
<span className={styles.metaValue}>{doc.source}</span> <span className={styles.metaLabel}>Source</span>
</div> <span className={styles.metaValue}>{doc.source}</span>
<div className={styles.metaItem}> </div>
<span className={styles.metaLabel}>Type</span> <div className={styles.metaItem}>
<span className={styles.metaValue}>{doc.type}</span> <span className={styles.metaLabel}>Type</span>
</div> <span className={styles.metaValue}>{doc.type}</span>
<div className={styles.metaItem}> </div>
<span className={styles.metaLabel}>Date</span> <div className={styles.metaItem}>
<span className={styles.metaValue}>{doc.date ?? "—"}</span> <span className={styles.metaLabel}>Date</span>
</div> <span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div> </div>
</div> </div>
</div>
{doc.pdf_url && (
<button {doc.pdf_url && (
className={styles.openButton} <button
onClick={() => openPdf(doc.pdf_url)} className={styles.openButton}
> onClick={() => openPdf(doc.pdf_url)}
Open PDF >
</button> Open PDF
)} </button>
</div> )}
</div> </div>
))} </div>
))}
{totalPages > 1 && (
<div className={styles.pagination}> <Pagination
<button className={styles.paginationButton} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={currentPage===1}>Previous</button> currentPage={safePage}
{(() => { totalPages={totalPages}
const maxShow = 5; onPageChange={setCurrentPage}
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); </div>
else pages.push(1,'...', currentPage-1, currentPage, currentPage+1,'...', totalPages); </div>
} </div>
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>
);
} }
+211 -263
View File
@@ -1,264 +1,212 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react"; 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 Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const DEFAULT_ITEMS_PER_PAGE = 8;
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]); export default function RecentUpdatesPage() {
const [loading, setLoading] = useState(true); const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [sourceFilter, setSourceFilter] = useState("All Sources"); const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState("All Types"); const [sourceFilter, setSourceFilter] = useState("All Sources");
const [currentPage, setCurrentPage] = useState(1); const [typeFilter, setTypeFilter] = useState("All Types");
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
useEffect(() => {
getDocuments() useEffect(() => {
.then((data) => setUpdates(Array.isArray(data) ? data : [])) getDocuments()
.catch((err) => { .then((data) => setUpdates(Array.isArray(data) ? data : []))
console.error("API ERROR:", err); .catch((err) => {
setUpdates([]); console.error("API ERROR:", err);
}) setUpdates([]);
.finally(() => setLoading(false)); })
}, []); .finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))], const allSources = useMemo(
[updates] () => ["All Sources", ...new Set(updates.map((u) => u.source))],
); [updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))], const allTypes = useMemo(
[updates] () => ["All Types", ...new Set(updates.map((u) => u.type))],
); [updates]
);
const filtered = useMemo(() => {
return updates const filtered = useMemo(() => {
.filter((u) => { return updates
const sourceMatch = .filter((u) => {
sourceFilter === "All Sources" || u.source === sourceFilter; const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter; const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
}) return sourceMatch && typeMatch;
.sort((a, b) => })
String(b.date ?? "").localeCompare(String(a.date ?? "")) .sort((a, b) =>
); String(b.date ?? "").localeCompare(String(a.date ?? ""))
}, [updates, sourceFilter, typeFilter]); );
}, [updates, sourceFilter, typeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const safePage = Math.min(currentPage, totalPages); const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * itemsPerPage, const paginated = filtered.slice(
safePage * itemsPerPage (safePage - 1) * itemsPerPage,
); safePage * itemsPerPage
);
useEffect(() => {
const apply = () => { useEffect(() => {
const w = window.innerWidth; const apply = () => {
if (w < 640) setItemsPerPage(3); const w = window.innerWidth;
else if (w < 900) setItemsPerPage(6); if (w < 640) setItemsPerPage(3);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE); else if (w < 900) setItemsPerPage(6);
}; else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
apply(); };
window.addEventListener('resize', apply); apply();
return () => window.removeEventListener('resize', apply); window.addEventListener('resize', apply);
}, []); return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return ( if (loading) {
<div className={styles.container}> return (
<p>Loading updates...</p> <div className={styles.container}>
</div> <p>Loading updates...</p>
); </div>
} );
}
return (
<div className={styles.container}> return (
<div className={styles.maxWidth}> <div className={styles.container}>
{/* HEADER */} <div className={styles.maxWidth}>
<div className={styles.header}> {/* HEADER */}
<div className={styles.headerFlex}> <div className={styles.header}>
<TrendingUp size={28} className={styles.headerIcon} /> <div className={styles.headerFlex}>
<h1 className={styles.title}>Recent Updates</h1> <TrendingUp size={28} className={styles.headerIcon} />
</div> <h1 className={styles.title}>Recent Updates</h1>
<p className={styles.subtitle}> </div>
Latest cosmetic regulatory updates and monitoring activity <p className={styles.subtitle}>
</p> Latest cosmetic regulatory updates and monitoring activity
</div> </p>
</div>
{/* STATS */}
<div className={styles.statsGrid}> {/* STATS */}
<div className={styles.statCard}> <div className={styles.statsGrid}>
<p className={styles.statLabel}>Total Updates</p> <div className={styles.statCard}>
<h2 className={styles.statValue}>{updates.length}</h2> <p className={styles.statLabel}>Total Updates</p>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}> <h2 className={styles.statValue}>{updates.length}</h2>
<TrendingUp size={18} /> <div className={`${styles.statIcon} ${styles.statIconBlue}`}>
</div> <TrendingUp size={18} />
</div> </div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p> <div className={styles.statCard}>
<h2 className={styles.statValue}>{allSources.length - 1}</h2> <p className={styles.statLabel}>Sources</p>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}> <h2 className={styles.statValue}>{allSources.length - 1}</h2>
<Filter size={18} /> <div className={`${styles.statIcon} ${styles.statIconGreen}`}>
</div> <Filter size={18} />
</div> </div>
</div> </div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}> {/* FILTERS */}
<div className={styles.filterLabel}> <div className={styles.filtersBox}>
<Filter size={16} /> <div className={styles.filterLabel}>
<span>Filters</span> <Filter size={16} />
</div> <span>Filters</span>
</div>
<select
className={styles.select} <select
value={sourceFilter} className={styles.select}
onChange={(e) => setSourceFilter(e.target.value)} value={sourceFilter}
> onChange={(e) => setSourceFilter(e.target.value)}
{allSources.map((s) => ( >
<option key={s} value={s}> {allSources.map((s) => (
{s} <option key={s} value={s}>
</option> {s}
))} </option>
</select> ))}
</select>
<select
className={styles.select} <select
value={typeFilter} className={styles.select}
onChange={(e) => setTypeFilter(e.target.value)} value={typeFilter}
> onChange={(e) => setTypeFilter(e.target.value)}
{allTypes.map((t) => ( >
<option key={t} value={t}> {allTypes.map((t) => (
{t} <option key={t} value={t}>
</option> {t}
))} </option>
</select> ))}
</div> </select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}> {/* RESULTS */}
<span className={styles.resultsCount}>{filtered.length}</span>{" "} <div className={styles.resultsInfo}>
updates found <span className={styles.resultsCount}>{filtered.length}</span>{" "}
</div> updates found
</div>
{/* TABLE */}
<div className={styles.table}> {/* TABLE */}
<div className={styles.tableHeader}> <div className={styles.table}>
<span className={styles.headerCell}>Status</span> <div className={styles.tableHeader}>
<span className={styles.headerCell}>Title</span> <span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Ingredient</span> <span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Source</span> <span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Type</span> <span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Date</span> <span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>View</span> <span className={styles.headerCell}>Date</span>
</div> <span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div {paginated.map((u, index) => (
key={u.id} <div
className={`${styles.tableRow} ${ key={u.id}
index < paginated.length - 1 className={`${styles.tableRow} ${
? styles.tableRowBorder index < paginated.length - 1
: "" ? styles.tableRowBorder
}`} : ""
> }`}
<div> >
<span <div>
className={`${styles.statusBadge} ${styles.statusBadgeNew}`} <span
> className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
New >
</span> New
</div> </span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title} <div className={`${styles.titleCell} ${styles.cellTruncate}`}>
</div> {u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div> <div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.type}</div> <div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.date ?? "—"}</div> <div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && ( <div>
<button {u.pdf_url && (
className={styles.viewButton} <button
onClick={() => openPdf(u.pdf_url)} className={styles.viewButton}
> onClick={() => openPdf(u.pdf_url)}
<Eye size={14} /> >
</button> <Eye size={14} />
)} </button>
</div> )}
</div> </div>
))} </div>
</div> ))}
</div>
{/* PAGINATION */}
{totalPages > 1 && ( <Pagination
<div className={styles.pagination}> currentPage={safePage}
<button totalPages={totalPages}
className={styles.paginationButton} onPageChange={setCurrentPage}
disabled={safePage === 1} />
onClick={() => </div>
setCurrentPage((p) => Math.max(1, p - 1)) </div>
} );
>
Previous
</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}
disabled={safePage === totalPages}
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
>
Next
</button>
</div>
)}
</div>
</div>
);
} }
+237 -295
View File
@@ -1,295 +1,237 @@
.container { .container {
padding: 2rem; padding: 2rem;
} }
.maxWidth { .maxWidth {
max-width: 80rem; max-width: 80rem;
} }
.header { .header {
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
} }
.title { .title {
font-size: 2.25rem; font-size: 2.25rem;
font-weight: 700; font-weight: 700;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.1; line-height: 1.1;
} }
.description { .description {
color: rgb(75, 85, 99); color: rgb(75, 85, 99);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.6; line-height: 1.6;
} }
.filtersBox { .filtersBox {
background-color: white; background-color: white;
border: 1px solid rgb(229, 231, 235); border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1.25rem; padding: 1.25rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filterLabel { .filterLabel {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
} }
.filterIcon { .filterIcon {
font-size: 1rem; font-size: 1rem;
} }
.select { .select {
border: 1px solid rgb(209, 213, 219); border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
background-color: white; background-color: white;
cursor: pointer; cursor: pointer;
} }
.select:focus { .select:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246); box-shadow: 0 0 0 2px rgb(59, 130, 246);
} }
.sortWrapper { .sortWrapper {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.sortIcon { .sortIcon {
color: rgb(107, 114, 128); color: rgb(107, 114, 128);
} }
.statsRow { .statsRow {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.statsText { .statsText {
font-size: 0.875rem; font-size: 0.875rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
} }
.statsNumber { .statsNumber {
font-weight: 600; font-weight: 600;
} }
.exportButton { .exportButton {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border: 1px solid rgb(209, 213, 219); border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
padding: 0.375rem 1rem; padding: 0.375rem 1rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
background-color: white; background-color: white;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.exportButton:hover { .exportButton:hover {
background-color: rgb(249, 250, 251); background-color: rgb(249, 250, 251);
} }
.table { .table {
background-color: white; background-color: white;
border: 1px solid rgb(229, 231, 235); border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.tableHeader { .tableHeader {
display: grid; display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr; grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
background-color: rgb(249, 250, 251); background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235); border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
} }
.tableRow { .tableRow {
display: grid; display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr; grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
align-items: center; align-items: center;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
} }
.tableRow span, .tableRow span,
.titleCell span, .titleCell span,
.titleCell { .titleCell {
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
} }
.headerCell { .headerCell {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
} }
.tableRow { .tableRow {
display: grid; display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr; grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
align-items: center; align-items: center;
} }
.tableRow:hover { .tableRow:hover {
background-color: rgb(249, 250, 251); background-color: rgb(249, 250, 251);
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.tableRowBorder { .tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235); border-bottom: 1px solid rgb(229, 231, 235);
} }
.titleCell { .titleCell {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.625rem; gap: 0.625rem;
} }
.titleIcon { .titleIcon {
color: rgb(156, 163, 175); color: rgb(156, 163, 175);
margin-top: 0.125rem; margin-top: 0.125rem;
flex-shrink: 0; flex-shrink: 0;
} }
.titleText { .titleText {
font-size: 0.875rem; font-size: 0.875rem;
color: rgb(31, 41, 55); color: rgb(31, 41, 55);
line-height: 1.5; line-height: 1.5;
} }
.ingredientBadge { .ingredientBadge {
display: inline-block; display: inline-block;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.625rem; padding: 0.625rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid rgb(209, 213, 219); border: 1px solid rgb(209, 213, 219);
background-color: rgb(243, 244, 246); background-color: rgb(243, 244, 246);
} }
.ingredientBadgeBold { .ingredientBadgeBold {
font-weight: 600; font-weight: 600;
border-color: rgb(209, 213, 219); border-color: rgb(209, 213, 219);
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
} }
.ingredientBadgeNormal { .ingredientBadgeNormal {
border-color: rgb(229, 231, 235); border-color: rgb(229, 231, 235);
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
} }
.cell { .cell {
font-size: 0.875rem; font-size: 0.875rem;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
} }
.cellWhitespace { .cellWhitespace {
white-space: nowrap; white-space: nowrap;
} }
.openButton { .openButton {
background-color: rgb(37, 99, 235); background-color: rgb(37, 99, 235);
color: white; color: white;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
padding: 0.375rem 1rem; padding: 0.375rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.openButton:hover { .openButton:hover {
background-color: rgb(29, 78, 216); background-color: rgb(29, 78, 216);
} }
.pagination {
display: flex; /* Mobile: make each table row a card */
align-items: center; @media (max-width: 640px) {
justify-content: center; .tableHeader { display: none; }
gap: 0.5rem; .tableRow { display: block; padding: 12px; border-bottom: 1px solid rgb(229,231,235); }
margin-top: 1.5rem; .titleCell { font-weight:700; font-size:1rem; display:flex; align-items:center; gap:8px }
flex-wrap: wrap; .tableRow span { display:block; margin-top:6px; color: rgb(17,24,39) }
overflow-x: auto; .openButton { width:100%; display:block }
padding-bottom: 0.5rem; }
}
.paginationButton {
padding: 0.45rem 0.85rem;
font-size: 0.85rem;
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;
}
.paginationButton:hover:not(:disabled) {
background-color: rgb(249, 250, 251);
}
.paginationButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageNumber {
min-width: 2rem;
padding: 0.45rem 0.75rem;
height: 2.25rem;
font-size: 0.875rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
}
.pageNumberActive {
background-color: rgb(37, 99, 235);
color: white;
border-color: rgb(37, 99, 235);
}
.pageNumberInactive {
border: 1px solid rgb(209, 213, 219);
color: rgb(55, 65, 81);
background-color: white;
}
.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 }
}
+78
View File
@@ -0,0 +1,78 @@
/* Style unique de pagination, partagé par toutes les pages.
Reprend l'accent bleu #2563eb du site (boutons primaires, liens d'action). */
.pagination {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.5rem;
}
.navButton,
.page,
.pageActive {
min-width: 2.25rem;
height: 2.25rem;
padding: 0 0.75rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
border-radius: 0.5rem;
border: 1px solid rgb(209, 213, 219);
background-color: white;
color: rgb(55, 65, 81);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.navButton:hover:not(:disabled),
.page:hover {
background-color: rgb(249, 250, 251);
border-color: rgb(156, 163, 175);
}
.navButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageActive {
background-color: rgb(37, 99, 235);
border-color: rgb(37, 99, 235);
color: white;
}
.pageActive:hover {
background-color: rgb(29, 78, 216);
border-color: rgb(29, 78, 216);
}
.navButton:focus-visible,
.page:focus-visible,
.pageActive:focus-visible {
outline: 2px solid rgb(37, 99, 235);
outline-offset: 2px;
}
.ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 2.25rem;
color: rgb(107, 114, 128);
user-select: none;
}
@media (max-width: 480px) {
.navButton,
.page,
.pageActive {
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
font-size: 0.8125rem;
}
}
+278 -330
View File
@@ -1,330 +1,278 @@
.container { .container {
padding: 2rem; padding: 2rem;
} }
.maxWidth { .maxWidth {
max-width: 96rem; max-width: 96rem;
} }
.header { .header {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.headerFlex { .headerFlex {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.headerIcon { .headerIcon {
color: rgb(37, 99, 235); color: rgb(37, 99, 235);
font-size: 1.75rem; font-size: 1.75rem;
} }
.title { .title {
font-size: 2.25rem; font-size: 2.25rem;
font-weight: 700; font-weight: 700;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
line-height: 1.1; line-height: 1.1;
} }
.subtitle { .subtitle {
color: rgb(75, 85, 99); color: rgb(75, 85, 99);
margin-bottom: 2rem; margin-bottom: 2rem;
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.6; line-height: 1.6;
} }
.statsGrid { .statsGrid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.statCard { .statCard {
background-color: white; background-color: white;
border: 1px solid rgb(229, 231, 235); border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1.25rem; padding: 1.25rem;
} }
.statLabel { .statLabel {
color: rgb(75, 85, 99); color: rgb(75, 85, 99);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.statValue { .statValue {
font-size: 1.875rem; font-size: 1.875rem;
font-weight: bold; font-weight: bold;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
} }
.statIcon { .statIcon {
margin-top: 0.75rem; margin-top: 0.75rem;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 0.5rem; border-radius: 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.statIconBlue { .statIconBlue {
background-color: rgb(219, 234, 254); background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235); color: rgb(37, 99, 235);
} }
.statIconGreen { .statIconGreen {
background-color: rgb(220, 252, 231); background-color: rgb(220, 252, 231);
color: rgb(34, 197, 94); color: rgb(34, 197, 94);
} }
.filtersBox { .filtersBox {
background-color: white; background-color: white;
border: 1px solid rgb(229, 231, 235); border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filterLabel { .filterLabel {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
} }
.filterIcon { .filterIcon {
font-size: 1rem; font-size: 1rem;
} }
.select { .select {
border: 1px solid rgb(209, 213, 219); border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
background-color: white; background-color: white;
cursor: pointer; cursor: pointer;
} }
.select:focus { .select:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246); box-shadow: 0 0 0 2px rgb(59, 130, 246);
} }
.exportWrapper { .exportWrapper {
margin-left: auto; margin-left: auto;
} }
.exportButton { .exportButton {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border: 1px solid rgb(209, 213, 219); border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
padding: 0.375rem 1rem; padding: 0.375rem 1rem;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
background-color: white; background-color: white;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.exportButton:hover { .exportButton:hover {
background-color: rgb(249, 250, 251); background-color: rgb(249, 250, 251);
} }
.resultsInfo { .resultsInfo {
color: rgb(75, 85, 99); color: rgb(75, 85, 99);
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
.resultsCount { .resultsCount {
font-weight: 600; font-weight: 600;
} }
.table { .table {
background-color: white; background-color: white;
border: 1px solid rgb(229, 231, 235); border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.tableHeader { .tableHeader {
display: grid; display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr; grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem; gap: 1rem;
background-color: rgb(249, 250, 251); background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235); border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
} }
.headerCell { .headerCell {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: rgb(55, 65, 81); color: rgb(55, 65, 81);
} }
.tableRow { .tableRow {
display: grid; display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr; grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem; gap: 1rem;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
align-items: center; align-items: center;
} }
.tableRow:hover { .tableRow:hover {
background-color: rgb(249, 250, 251); background-color: rgb(249, 250, 251);
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.tableRowBorder { .tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235); border-bottom: 1px solid rgb(229, 231, 235);
} }
.statusBadge { .statusBadge {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 9999px; border-radius: 9999px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
.statusBadgeNew { .statusBadgeNew {
background-color: rgb(220, 252, 231); background-color: rgb(220, 252, 231);
color: rgb(22, 163, 74); color: rgb(22, 163, 74);
} }
.statusBadgeUpdated { .statusBadgeUpdated {
background-color: rgb(219, 234, 254); background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235); color: rgb(37, 99, 235);
} }
.titleCell { .titleCell {
font-weight: 500; font-weight: 500;
color: rgb(17, 24, 39); color: rgb(17, 24, 39);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.cell { .cell {
font-size: 0.875rem; font-size: 0.875rem;
color: rgb(75, 85, 99); color: rgb(75, 85, 99);
} }
.cellTruncate { .cellTruncate {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.viewButton { .viewButton {
background-color: rgb(37, 99, 235); background-color: rgb(37, 99, 235);
color: white; color: white;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.viewButton:hover { .viewButton:hover {
background-color: rgb(29, 78, 216); background-color: rgb(29, 78, 216);
} }
.pagination { @media (max-width: 1024px) {
display: flex; .statsGrid {
align-items: center; grid-template-columns: repeat(2, 1fr);
justify-content: center; }
gap: 0.5rem;
margin-top: 1.5rem; .tableHeader,
} .tableRow {
grid-template-columns: 0.6fr 1.5fr 0.8fr 1fr 0.8fr 0.6fr 0.5fr;
.paginationButton { }
padding: 0.5rem 1rem; }
font-size: 0.875rem;
border: 1px solid rgb(209, 213, 219); @media (max-width: 768px) {
border-radius: 0.5rem; .statsGrid {
color: rgb(55, 65, 81); grid-template-columns: 1fr;
background-color: white; }
cursor: pointer;
transition: all 0.2s; .tableHeader,
} .tableRow {
grid-template-columns: 0.6fr 1.2fr 0.6fr 0.8fr 0.6fr 0.5fr 0.4fr;
.paginationButton:hover:not(:disabled) { gap: 0.5rem;
background-color: rgb(249, 250, 251); padding: 0.75rem 1rem;
} }
.paginationButton:disabled { .headerCell,
opacity: 0.4; .cell {
cursor: not-allowed; font-size: 0.75rem;
} }
}
.pageNumber {
width: 2.25rem;
height: 2.25rem;
font-size: 0.875rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
}
.pageNumberActive {
background-color: rgb(37, 99, 235);
color: white;
}
.pageNumberInactive {
border: 1px solid rgb(209, 213, 219);
color: rgb(55, 65, 81);
background-color: white;
}
.pageNumberInactive:hover {
background-color: rgb(249, 250, 251);
}
@media (max-width: 1024px) {
.statsGrid {
grid-template-columns: repeat(2, 1fr);
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.5fr 0.8fr 1fr 0.8fr 0.6fr 0.5fr;
}
}
@media (max-width: 768px) {
.statsGrid {
grid-template-columns: 1fr;
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.2fr 0.6fr 0.8fr 0.6fr 0.5fr 0.4fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.headerCell,
.cell {
font-size: 0.75rem;
}
}