Rétablit les fins de ligne CRLF sur les fichiers du commit précédent
Build & Deploy / build (push) Successful in 27s
Build & Deploy / build (push) Successful in 27s
Le script d'édition du commit précédent a réécrit ces cinq fichiers en LF alors que le dépôt les stocke en CRLF, ce qui gonflait le diff au fichier entier et aurait rendu les prochains merges pénibles. Contenu strictement identique, seules les fins de ligne reviennent à la convention du dépôt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+258
-258
@@ -1,259 +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 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>
|
||||
);
|
||||
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
-191
@@ -1,192 +1,192 @@
|
||||
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>
|
||||
);
|
||||
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
-211
@@ -1,212 +1,212 @@
|
||||
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>
|
||||
);
|
||||
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
-237
@@ -1,237 +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);
|
||||
}
|
||||
|
||||
|
||||
/* 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 }
|
||||
}
|
||||
|
||||
@@ -1,278 +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);
|
||||
}
|
||||
|
||||
@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