Pagination : un seul composant partagé pour les trois pages
Build & Deploy / build (push) Successful in 26s
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user