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 {
Filter,
ArrowUpDown,
File as FileIcon,
} from "lucide-react";
import styles from "../styles/DocumentsPage.module.css";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
bold?: boolean;
};
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => {
setDocuments(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("API Error:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))],
[documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))],
[documents]
);
const allDates = useMemo(
() => [
"All Dates",
...new Set(
documents.map((d) => String(d.date ?? "").slice(0, 4))
),
],
[documents]
);
const filtered = useMemo(() => {
return documents
.filter((d) => {
const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch =
dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => {
const dateA = String(a.date ?? "");
const dateB = String(b.date ?? "");
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 === "Title (A-Z)") return titleA.localeCompare(titleB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
return 0;
});
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
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
);
// adjust items per page based on viewport width
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading documents...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h2 className={styles.title}>Document Database</h2>
<p className={styles.description}>
Complete collection of regulatory monitoring documents
</p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters:</span>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className={styles.select}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className={styles.select}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className={styles.select}
>
{allDates.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} />
<select
value={sortBy}
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="Title (A-Z)">Title (A-Z)</option>
<option value="Title (Z-A)">Title (Z-A)</option>
</select>
</div>
</div>
{/* STATS */}
<div className={styles.statsRow}>
<p className={styles.statsText}>
Total Documents:
<span className={styles.statsNumber}>
{" "}
{filtered.length}
</span>
</p>
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span>Title</span>
<span>Ingredient</span>
<span>Source</span>
<span>Type</span>
<span>Date</span>
<span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div
key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
className={styles.tableRow}
>
<div className={styles.titleCell}>
<FileIcon size={16} />
<span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span>
<span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a
href={doc.pdf_url}
target="_blank"
rel="noreferrer"
>
<button className={styles.openButton}>
Open
</button>
</a>
</div>
</div>
))}
</div>
{/* PAGINATION */}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
onClick={() =>
setCurrentPage(Math.max(1, safePage - 1))
}
disabled={safePage === 1}
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>
);
import { useEffect, useMemo, useState } from "react";
import {
Filter,
ArrowUpDown,
File as FileIcon,
} from "lucide-react";
import styles from "../styles/DocumentsPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
bold?: boolean;
};
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => {
setDocuments(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("API Error:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))],
[documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))],
[documents]
);
const allDates = useMemo(
() => [
"All Dates",
...new Set(
documents.map((d) => String(d.date ?? "").slice(0, 4))
),
],
[documents]
);
const filtered = useMemo(() => {
return documents
.filter((d) => {
const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch =
dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => {
const dateA = String(a.date ?? "");
const dateB = String(b.date ?? "");
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 === "Title (A-Z)") return titleA.localeCompare(titleB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
return 0;
});
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
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
);
// adjust items per page based on viewport width
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading documents...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h2 className={styles.title}>Document Database</h2>
<p className={styles.description}>
Complete collection of regulatory monitoring documents
</p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters:</span>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className={styles.select}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className={styles.select}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className={styles.select}
>
{allDates.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} />
<select
value={sortBy}
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="Title (A-Z)">Title (A-Z)</option>
<option value="Title (Z-A)">Title (Z-A)</option>
</select>
</div>
</div>
{/* STATS */}
<div className={styles.statsRow}>
<p className={styles.statsText}>
Total Documents:
<span className={styles.statsNumber}>
{" "}
{filtered.length}
</span>
</p>
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span>Title</span>
<span>Ingredient</span>
<span>Source</span>
<span>Type</span>
<span>Date</span>
<span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div
key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
className={styles.tableRow}
>
<div className={styles.titleCell}>
<FileIcon size={16} />
<span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span>
<span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a
href={doc.pdf_url}
target="_blank"
rel="noreferrer"
>
<button className={styles.openButton}>
Open
</button>
</a>
</div>
</div>
))}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
}
+191 -205
View File
@@ -1,206 +1,192 @@
import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
// FETCH API
useEffect(() => {
getDocuments()
.then((data) => setDocuments(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
// responsive items per page
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(8);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types",
...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates",
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => {
return documents
.filter((d) => {
const queryMatch =
searchQuery === "" ||
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 safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
})
.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>;
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h1 className={styles.title}>Ingredient Search</h1>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
type="text"
placeholder="Search ingredient..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={20} className={styles.searchIcon} />
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterHeader}>
<Filter size={16} className={styles.filterIcon} />
<span>Filters</span>
</div>
<div className={styles.filterControls}>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</select>
</div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}>
{filtered.length === 0 ? (
<div className={styles.emptyState}>
<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 paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
return (
<>
{paginated.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
))}
{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>
);
import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
// FETCH API
useEffect(() => {
getDocuments()
.then((data) => setDocuments(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
// responsive items per page
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(8);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types",
...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates",
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => {
return documents
.filter((d) => {
const queryMatch =
searchQuery === "" ||
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 safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
})
.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>;
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h1 className={styles.title}>Ingredient Search</h1>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
type="text"
placeholder="Search ingredient..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={20} className={styles.searchIcon} />
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterHeader}>
<Filter size={16} className={styles.filterIcon} />
<span>Filters</span>
</div>
<div className={styles.filterControls}>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</select>
</div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}>
{filtered.length === 0 ? (
<div className={styles.emptyState}>
<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 paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
return (
<>
{paginated.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
))}
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</>
);
})()
)}
</div>
</div>
</div>
);
}
+211 -263
View File
@@ -1,264 +1,212 @@
import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react";
import styles from "../styles/RecentUpdatesPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
useEffect(() => {
getDocuments()
.then((data) => setUpdates(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setUpdates([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))],
[updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))],
[updates]
);
const filtered = useMemo(() => {
return updates
.filter((u) => {
const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
})
.sort((a, b) =>
String(b.date ?? "").localeCompare(String(a.date ?? ""))
);
}, [updates, sourceFilter, typeFilter]);
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
);
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading updates...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<div className={styles.headerFlex}>
<TrendingUp size={28} className={styles.headerIcon} />
<h1 className={styles.title}>Recent Updates</h1>
</div>
<p className={styles.subtitle}>
Latest cosmetic regulatory updates and monitoring activity
</p>
</div>
{/* STATS */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<p className={styles.statLabel}>Total Updates</p>
<h2 className={styles.statValue}>{updates.length}</h2>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}>
<TrendingUp size={18} />
</div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p>
<h2 className={styles.statValue}>{allSources.length - 1}</h2>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}>
<Filter size={18} />
</div>
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters</span>
</div>
<select
className={styles.select}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
className={styles.select}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span>{" "}
updates found
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>Date</span>
<span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div
key={u.id}
className={`${styles.tableRow} ${
index < paginated.length - 1
? styles.tableRowBorder
: ""
}`}
>
<div>
<span
className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
>
New
</span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && (
<button
className={styles.viewButton}
onClick={() => openPdf(u.pdf_url)}
>
<Eye size={14} />
</button>
)}
</div>
</div>
))}
</div>
{/* PAGINATION */}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.paginationButton}
disabled={safePage === 1}
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 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 (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>
);
import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react";
import styles from "../styles/RecentUpdatesPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
useEffect(() => {
getDocuments()
.then((data) => setUpdates(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setUpdates([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))],
[updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))],
[updates]
);
const filtered = useMemo(() => {
return updates
.filter((u) => {
const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
})
.sort((a, b) =>
String(b.date ?? "").localeCompare(String(a.date ?? ""))
);
}, [updates, sourceFilter, typeFilter]);
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
);
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading updates...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<div className={styles.headerFlex}>
<TrendingUp size={28} className={styles.headerIcon} />
<h1 className={styles.title}>Recent Updates</h1>
</div>
<p className={styles.subtitle}>
Latest cosmetic regulatory updates and monitoring activity
</p>
</div>
{/* STATS */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<p className={styles.statLabel}>Total Updates</p>
<h2 className={styles.statValue}>{updates.length}</h2>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}>
<TrendingUp size={18} />
</div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p>
<h2 className={styles.statValue}>{allSources.length - 1}</h2>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}>
<Filter size={18} />
</div>
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters</span>
</div>
<select
className={styles.select}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
className={styles.select}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span>{" "}
updates found
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>Date</span>
<span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div
key={u.id}
className={`${styles.tableRow} ${
index < paginated.length - 1
? styles.tableRowBorder
: ""
}`}
>
<div>
<span
className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
>
New
</span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && (
<button
className={styles.viewButton}
onClick={() => openPdf(u.pdf_url)}
>
<Eye size={14} />
</button>
)}
</div>
</div>
))}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
}
+237 -295
View File
@@ -1,295 +1,237 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
line-height: 1.1;
}
.description {
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
font-size: 1.125rem;
line-height: 1.6;
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.sortWrapper {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sortIcon {
color: rgb(107, 114, 128);
}
.statsRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.statsText {
font-size: 0.875rem;
color: rgb(55, 65, 81);
}
.statsNumber {
font-weight: 600;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.25rem;
color: rgb(17, 24, 39);
}
.tableRow {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
color: rgb(17, 24, 39);
}
.tableRow span,
.titleCell span,
.titleCell {
color: rgb(17, 24, 39);
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.titleCell {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.titleIcon {
color: rgb(156, 163, 175);
margin-top: 0.125rem;
flex-shrink: 0;
}
.titleText {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.5;
}
.ingredientBadge {
display: inline-block;
font-size: 0.75rem;
padding: 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgb(209, 213, 219);
background-color: rgb(243, 244, 246);
}
.ingredientBadgeBold {
font-weight: 600;
border-color: rgb(209, 213, 219);
color: rgb(17, 24, 39);
}
.ingredientBadgeNormal {
border-color: rgb(229, 231, 235);
color: rgb(55, 65, 81);
}
.cell {
font-size: 0.875rem;
color: rgb(17, 24, 39);
}
.cellWhitespace {
white-space: nowrap;
}
.openButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.375rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.openButton:hover {
background-color: rgb(29, 78, 216);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
flex-wrap: wrap;
overflow-x: auto;
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 }
}
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
line-height: 1.1;
}
.description {
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
font-size: 1.125rem;
line-height: 1.6;
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.sortWrapper {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sortIcon {
color: rgb(107, 114, 128);
}
.statsRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.statsText {
font-size: 0.875rem;
color: rgb(55, 65, 81);
}
.statsNumber {
font-weight: 600;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.25rem;
color: rgb(17, 24, 39);
}
.tableRow {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
color: rgb(17, 24, 39);
}
.tableRow span,
.titleCell span,
.titleCell {
color: rgb(17, 24, 39);
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.titleCell {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.titleIcon {
color: rgb(156, 163, 175);
margin-top: 0.125rem;
flex-shrink: 0;
}
.titleText {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.5;
}
.ingredientBadge {
display: inline-block;
font-size: 0.75rem;
padding: 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgb(209, 213, 219);
background-color: rgb(243, 244, 246);
}
.ingredientBadgeBold {
font-weight: 600;
border-color: rgb(209, 213, 219);
color: rgb(17, 24, 39);
}
.ingredientBadgeNormal {
border-color: rgb(229, 231, 235);
color: rgb(55, 65, 81);
}
.cell {
font-size: 0.875rem;
color: rgb(17, 24, 39);
}
.cellWhitespace {
white-space: nowrap;
}
.openButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.375rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.openButton:hover {
background-color: rgb(29, 78, 216);
}
/* 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 {
padding: 2rem;
}
.maxWidth {
max-width: 96rem;
}
.header {
margin-bottom: 2rem;
}
.headerFlex {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.headerIcon {
color: rgb(37, 99, 235);
font-size: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
}
.subtitle {
color: rgb(75, 85, 99);
margin-bottom: 2rem;
font-size: 1.125rem;
line-height: 1.6;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.statCard {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.statLabel {
color: rgb(75, 85, 99);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.statValue {
font-size: 1.875rem;
font-weight: bold;
color: rgb(17, 24, 39);
}
.statIcon {
margin-top: 0.75rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.statIconBlue {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.statIconGreen {
background-color: rgb(220, 252, 231);
color: rgb(34, 197, 94);
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.exportWrapper {
margin-left: auto;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.resultsInfo {
color: rgb(75, 85, 99);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.resultsCount {
font-weight: 600;
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.5rem;
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
padding: 1rem 1.5rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.statusBadge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.statusBadgeNew {
background-color: rgb(220, 252, 231);
color: rgb(22, 163, 74);
}
.statusBadgeUpdated {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.titleCell {
font-weight: 500;
color: rgb(17, 24, 39);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.cellTruncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.viewButton:hover {
background-color: rgb(29, 78, 216);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.paginationButton {
padding: 0.5rem 1rem;
font-size: 0.875rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
color: rgb(55, 65, 81);
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 {
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;
}
}
.container {
padding: 2rem;
}
.maxWidth {
max-width: 96rem;
}
.header {
margin-bottom: 2rem;
}
.headerFlex {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.headerIcon {
color: rgb(37, 99, 235);
font-size: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
}
.subtitle {
color: rgb(75, 85, 99);
margin-bottom: 2rem;
font-size: 1.125rem;
line-height: 1.6;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.statCard {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.statLabel {
color: rgb(75, 85, 99);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.statValue {
font-size: 1.875rem;
font-weight: bold;
color: rgb(17, 24, 39);
}
.statIcon {
margin-top: 0.75rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.statIconBlue {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.statIconGreen {
background-color: rgb(220, 252, 231);
color: rgb(34, 197, 94);
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.exportWrapper {
margin-left: auto;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.resultsInfo {
color: rgb(75, 85, 99);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.resultsCount {
font-weight: 600;
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.5rem;
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
padding: 1rem 1.5rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.statusBadge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.statusBadgeNew {
background-color: rgb(220, 252, 231);
color: rgb(22, 163, 74);
}
.statusBadgeUpdated {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.titleCell {
font-weight: 500;
color: rgb(17, 24, 39);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.cellTruncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.viewButton:hover {
background-color: rgb(29, 78, 216);
}
@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;
}
}