feat(publications): add multi-level year filtering for publications and patents

- Add Year filter dropdown with dynamic record counts in admin console (/console/publications)
- Add Year filter dropdown in public category publications page (/publications/[category])
- Comply with AC-44 WCAG 2.1 AA small text contrast rules (text-cobalt-dark)
- Verified with E2E test suite (Exit Code 0)
This commit is contained in:
2026-08-25 16:07:44 +09:00
parent d3dc7f19d9
commit 1410dcf89d
2 changed files with 198 additions and 42 deletions
@@ -20,6 +20,8 @@ export default function AdminPublicationsPage() {
const [publications, setPublications] = useState<AdminPublication[]>([]); const [publications, setPublications] = useState<AdminPublication[]>([]);
const [patents, setPatents] = useState<AdminPatent[]>([]); const [patents, setPatents] = useState<AdminPatent[]>([]);
const [filterCategory, setFilterCategory] = useState<string>("all"); const [filterCategory, setFilterCategory] = useState<string>("all");
const [filterYear, setFilterYear] = useState<string>("all");
const [filterPatentYear, setFilterPatentYear] = useState<string>("all");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null); const [successMsg, setSuccessMsg] = useState<string | null>(null);
@@ -248,10 +250,44 @@ export default function AdminPublicationsPage() {
} }
}; };
const filteredPubs = // Extract unique sorted years from publications
filterCategory === "all" const pubYears = Array.from(
? publications new Set(
: publications.filter((p) => p.category === filterCategory); publications
.map((p) => {
const match = (p.published_at || "").match(/\b(19\d\d|20\d\d)\b/);
return match ? match[1] : null;
})
.filter((y): y is string => Boolean(y))
)
).sort((a, b) => b.localeCompare(a));
// Extract unique sorted years from patents
const patentYears = Array.from(
new Set(
patents
.map((p) => {
const dateStr = p.published_at || p.application_at || p.registration_at || "";
const match = dateStr.match(/\b(19\d\d|20\d\d)\b/);
return match ? match[1] : null;
})
.filter((y): y is string => Boolean(y))
)
).sort((a, b) => b.localeCompare(a));
const filteredPubs = publications.filter((p) => {
const matchCategory = filterCategory === "all" || p.category === filterCategory;
const pubYear = (p.published_at || "").match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
const matchYear = filterYear === "all" || pubYear === filterYear;
return matchCategory && matchYear;
});
const filteredPatents = patents.filter((p) => {
if (filterPatentYear === "all") return true;
const dateStr = p.published_at || p.application_at || p.registration_at || "";
const patYear = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
return patYear === filterPatentYear;
});
return ( return (
<div> <div>
@@ -296,21 +332,58 @@ export default function AdminPublicationsPage() {
) : tab === "publications" ? ( ) : tab === "publications" ? (
<div> <div>
{/* Filter sub-bar */} {/* Filter sub-bar */}
<div className="flex items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
<span className="text-xs font-semibold text-ink/70">Filter:</span> <div className="flex items-center gap-2">
<select <span className="text-xs font-semibold text-ink/70">Category:</span>
value={filterCategory} <select
onChange={(e) => setFilterCategory(e.target.value)} value={filterCategory}
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" onChange={(e) => setFilterCategory(e.target.value)}
> className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
<option value="all">All Categories ({publications.length})</option> >
<option value="intl-journal-conf"> <option value="all">All Categories ({publications.length})</option>
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length}) <option value="intl-journal-conf">
</option> International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
<option value="domestic-journal-conf"> </option>
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length}) <option value="domestic-journal-conf">
</option> Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
</select> </option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-ink/70">Year:</span>
<select
value={filterYear}
onChange={(e) => setFilterYear(e.target.value)}
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
<option value="all">All Years ({publications.length})</option>
{pubYears.map((yr) => {
const count = publications.filter((p) => (p.published_at || "").includes(yr)).length;
return (
<option key={yr} value={yr}>
{yr} ({count})
</option>
);
})}
</select>
</div>
{(filterCategory !== "all" || filterYear !== "all") && (
<button
onClick={() => {
setFilterCategory("all");
setFilterYear("all");
}}
className="text-xs text-cobalt-dark hover:underline font-mono"
>
Reset filters
</button>
)}
<span className="text-xs text-ink/50 ml-auto">
Showing {filteredPubs.length} of {publications.length} records
</span>
</div> </div>
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm"> <div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
@@ -379,25 +452,64 @@ export default function AdminPublicationsPage() {
</div> </div>
) : ( ) : (
/* Patents Table */ /* Patents Table */
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm"> <div>
<table className="w-full text-left text-sm border-collapse"> {/* Filter sub-bar */}
<thead> <div className="flex flex-wrap items-center gap-3 mb-4">
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase"> <div className="flex items-center gap-2">
<th className="p-4 w-28">Date</th> <span className="text-xs font-semibold text-ink/70">Year:</span>
<th className="p-4">Title & Inventors</th> <select
<th className="p-4">App / Reg Numbers</th> value={filterPatentYear}
<th className="p-4 text-right">Actions</th> onChange={(e) => setFilterPatentYear(e.target.value)}
</tr> className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
</thead> >
<tbody className="divide-y divide-line"> <option value="all">All Years ({patents.length})</option>
{patents.length === 0 ? ( {patentYears.map((yr) => {
<tr> const count = patents.filter((p) => {
<td colSpan={4} className="p-6 text-center text-ink/60"> const d = p.published_at || p.application_at || p.registration_at || "";
No patents found. return d.includes(yr);
</td> }).length;
return (
<option key={yr} value={yr}>
{yr} ({count})
</option>
);
})}
</select>
</div>
{filterPatentYear !== "all" && (
<button
onClick={() => setFilterPatentYear("all")}
className="text-xs text-cobalt-dark hover:underline font-mono"
>
Reset filter
</button>
)}
<span className="text-xs text-ink/50 ml-auto">
Showing {filteredPatents.length} of {patents.length} records
</span>
</div>
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4 w-28">Date</th>
<th className="p-4">Title & Inventors</th>
<th className="p-4">App / Reg Numbers</th>
<th className="p-4 text-right">Actions</th>
</tr> </tr>
) : ( </thead>
patents.map((p) => ( <tbody className="divide-y divide-line">
{filteredPatents.length === 0 ? (
<tr>
<td colSpan={4} className="p-6 text-center text-ink/60">
No patents found.
</td>
</tr>
) : (
filteredPatents.map((p) => (
<tr key={p.id} className="hover:bg-ivory/50"> <tr key={p.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td> <td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
<td className="p-4"> <td className="p-4">
@@ -432,6 +544,7 @@ export default function AdminPublicationsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
{/* Pub Modal */} {/* Pub Modal */}
@@ -18,6 +18,8 @@ export function PublicationCategoryClient({ category }: { category: string }) {
const [publications, setPublications] = useState<Publication[]>([]); const [publications, setPublications] = useState<Publication[]>([]);
const [patents, setPatents] = useState<Patent[]>([]); const [patents, setPatents] = useState<Patent[]>([]);
const [selectedYear, setSelectedYear] = useState<string>("all");
useEffect(() => { useEffect(() => {
getPublications().then((p) => { getPublications().then((p) => {
if (p) setPublications(p); if (p) setPublications(p);
@@ -33,9 +35,9 @@ export function PublicationCategoryClient({ category }: { category: string }) {
patent: patents.length, patent: patents.length,
}; };
let records: any[] = []; let allRecords: any[] = [];
if (slug === "patent") { if (slug === "patent") {
records = patents.map((p) => ({ allRecords = patents.map((p) => ({
title: p.title, title: p.title,
inventors: p.inventors, inventors: p.inventors,
appNo: p.applicationNo, appNo: p.applicationNo,
@@ -47,7 +49,7 @@ export function PublicationCategoryClient({ category }: { category: string }) {
isPatent: true, isPatent: true,
})); }));
} else { } else {
records = publications allRecords = publications
.filter((p) => p.category === slug) .filter((p) => p.category === slug)
.map((p) => ({ .map((p) => ({
title: p.title, title: p.title,
@@ -59,6 +61,20 @@ export function PublicationCategoryClient({ category }: { category: string }) {
})); }));
} }
// Extract unique sorted years
const availableYears = Array.from(
new Set(
allRecords
.map((r) => (r.publishedAt || "").match(/\b(19\d\d|20\d\d)\b/)?.[1])
.filter((y): y is string => Boolean(y))
)
).sort((a, b) => b.localeCompare(a));
const records =
selectedYear === "all"
? allRecords
: allRecords.filter((r) => (r.publishedAt || "").includes(selectedYear));
return ( return (
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14"> <div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
{/* Page Header */} {/* Page Header */}
@@ -80,9 +96,36 @@ export function PublicationCategoryClient({ category }: { category: string }) {
{/* Record List Section */} {/* Record List Section */}
<section className="space-y-6 pt-6 md:pt-8 border-t border-line"> <section className="space-y-6 pt-6 md:pt-8 border-t border-line">
<div className="flex justify-between items-center border-b border-line pb-3"> <div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(e.target.value)}
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
>
<option value="all">All Years ({allRecords.length})</option>
{availableYears.map((yr) => {
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
return (
<option key={yr} value={yr}>
{yr} ({yrCount})
</option>
);
})}
</select>
{selectedYear !== "all" && (
<button
onClick={() => setSelectedYear("all")}
className="text-xs text-cobalt-dark hover:underline font-mono"
>
Reset
</button>
)}
</div>
<span className="font-mono text-xs text-ink-mute uppercase"> <span className="font-mono text-xs text-ink-mute uppercase">
{records.length} Records Showing {records.length} of {allRecords.length} Records
</span> </span>
</div> </div>