Compare commits
2
Commits
d3dc7f19d9
...
78a66b7ad4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78a66b7ad4 | ||
|
|
1410dcf89d |
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
|
|
||||||
export function getClientToken(): string {
|
export function getClientToken(): string {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
@@ -25,12 +26,15 @@ export function removeClientToken() {
|
|||||||
|
|
||||||
export function AdminHeader({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
|
export function AdminHeader({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-6 mb-6 border-b border-line">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-6 mb-6 border-b border-line gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-ink tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-bold text-ink tracking-tight">{title}</h1>
|
||||||
{description && <p className="text-sm text-ink/70 mt-1">{description}</p>}
|
{description && <p className="text-sm text-ink/70 mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="mt-4 sm:mt-0 flex gap-2">{action}</div>}
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{action}
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { setClientToken } from "./AdminComponents";
|
import { setClientToken } from "./AdminComponents";
|
||||||
import { adminVerifyToken } from "../../../lib/adminApi";
|
import { adminVerifyToken } from "../../../lib/adminApi";
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
|
|
||||||
export function AdminLoginForm({ onLoginSuccess }: { onLoginSuccess?: () => void }) {
|
export function AdminLoginForm({ onLoginSuccess }: { onLoginSuccess?: () => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -41,7 +42,10 @@ export function AdminLoginForm({ onLoginSuccess }: { onLoginSuccess?: () => void
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md bg-paper border border-line rounded-lg shadow-lg p-8">
|
<div className="w-full max-w-md bg-paper border border-line rounded-lg shadow-lg p-8 relative">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-ink">ANL Admin Console</h1>
|
<h1 className="text-2xl font-bold text-ink">ANL Admin Console</h1>
|
||||||
<p className="text-sm text-ink/70 mt-1">Enter your admin bearer token to manage laboratory datasets.</p>
|
<p className="text-sm text-ink/70 mt-1">Enter your admin bearer token to manage laboratory datasets.</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
|
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
|
||||||
import { AdminLoginForm } from "./_components/AdminLoginForm";
|
import { AdminLoginForm } from "./_components/AdminLoginForm";
|
||||||
import { adminVerifyToken } from "../../lib/adminApi";
|
import { adminVerifyToken } from "../../lib/adminApi";
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -98,7 +99,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="p-6 border-b border-line shrink-0">
|
<div className="p-6 border-b border-line shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
||||||
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink/60 mt-1">Lab Data Management</p>
|
<p className="text-xs text-ink/60 mt-1">Lab Data Management</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
+48
-5
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user