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 {
|
||||
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
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user