Compare commits

...
14 Commits
Author SHA1 Message Date
Godopu f3972cac27 feat(animation): implement subtle and high-performance scroll reveal animations across public pages
- Enhance Reveal component with HTMLAttributes passthrough and inline style merging
- Integrate Reveal into Home, Members, Publications, Lectures, and Standardization sections
- Apply lightweight stagger delay (max 360ms cap) without breaking grid stretch (h-full)
- Preserve ProjectPageView CSS Scroll Snap direct-child contract via as='article' in-place replacement
- Integrate Counter component for metrics and category badges
- Support prefers-reduced-motion media query and noscript fallback
- All 12 quality gates passed clean with unanimous PASS from all reviewers
2026-08-26 00:11:24 +09:00
Godopu b0bce3be8e style(members): set professor profile image container background to transparent with border only
- Remove bg-ivory from prof image wrapper container
- Apply bg-transparent and border border-line for clean transparent silhouette
- Passed all 12 E2E test gates (Exit Code 0)
2026-08-25 23:43:04 +09:00
Godopu ca6e85ce9b feat(members): replace Affiliation & Office box with Prof. Seok-Joo Koh profile image
- Copy prof_profile.png to refer_landing_page/public/images/
- Configure images.unoptimized in next.config.js for static export compatibility
- Render high-resolution portrait profile image in professor section
- All 12 quality gates passed clean (Exit Code 0)
2026-08-25 23:40:12 +09:00
Godopu 3dc89970c6 chore: ignore local environment and docker-compose configuration files 2026-08-25 07:57:49 +00:00
Godopu cda9c6ea4f style(console): relocate Admin badge directly below ThemeToggle in sidebar header
- Stack ThemeToggle and Admin badge vertically (flex-col items-end gap-1.5)
- Enhance layout hierarchy for ANL Console sidebar header
- Passed all 12 E2E test gates (Exit Code 0)
2026-08-25 16:54:15 +09:00
Godopu d1213e5c7f fix(publications,console): refine cross-filter counts and streamline admin header theme toggle
- Dynamically calculate category and year dropdown counts based on active intersection
- Remove duplicate ThemeToggle from AdminHeader body (relying on fixed sidebar header and login card)
- Achieve 100% unanimous PASS verdicts across all reviewer agents (Claude & Cline)
2026-08-25 16:46:30 +09:00
Godopu 78a66b7ad4 feat(console): add theme toggle button to admin header, sidebar, and login form
- Embed ThemeToggle component directly into AdminHeader top-right area
- Add ThemeToggle to AdminLayout fixed sidebar header next to Admin badge
- Add ThemeToggle to AdminLoginForm for seamless theme switching prior to authentication
- Cleanly passed all 12 E2E test gates (Exit Code 0)
2026-08-25 16:12:49 +09:00
Godopu 1410dcf89d 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)
2026-08-25 16:07:44 +09:00
Godopu d3dc7f19d9 refactor: eliminate dead code, redundant API queries, and enforce empty JSON array slices
- Remove redundant adminGetStatsSummary query and unused AdminStatsSummary interface
- Initialize empty repository slices with make([]T, 0) to ensure empty JSON arrays instead of null
- Add optional chaining in dashboard aggregations for strict null-safety
- Passed 100% unanimous peer reviews from planner-reviewer-claude-01 and reviewer-cline-01
2026-08-25 15:47:31 +09:00
Godopu 4c78ea819f fix(console): fix sidebar active route highlight matching and apply primary color hover styling
- Normalize route matching with cleanPath and cleanHref prefix comparison
- Highlight all sub-routes (Research Projects, Research Areas, Members, etc.) correctly when active
- Replace white hover effect with primary cobalt hover color (hover:bg-cobalt/10 hover:text-cobalt)
- Verified with E2E test suite (Exit Code 0)
2026-08-25 15:24:44 +09:00
Godopu 5285e3fd02 feat(console): hide AppHeader/Footer on /console and fix sidebar to full-height absolute/fixed position
- Hide Header and Footer components automatically when navigating to /console routes
- Set AdminLayout sidebar to fixed full-height navigation (md:fixed md:top-0 md:left-0 md:h-screen)
- Offset main dashboard content (md:ml-64) with independent scroll container
- Passed all 12 E2E and quality gates cleanly
2026-08-25 15:19:32 +09:00
Godopu 575fbdb6de fix(console,api): direct render AdminLoginForm on unauthenticated and harden URL normalization
- Directly render reusable AdminLoginForm component in AdminLayout when unauthenticated, eliminating fragile router redirects and stuck states
- Harden normalizeApiUrl in both api.ts and adminApi.ts to handle all path suffix combinations
- All tests and peer reviews passed with unanimous 100% PASS verdicts
2026-08-25 15:08:40 +09:00
Godopu 643ce8376c fix(api): ensure /api/v1 prefix normalization across all custom host URLs
- Add normalizeApiUrl() to ensure custom host URLs (e.g. http://puclouds-2:8080) automatically append /api/v1
- Fix 404 error when querying /stats/summary instead of /api/v1/stats/summary
2026-08-25 14:45:41 +09:00
Godopu 54fe425b3c fix(console): fix stuck authentication check and add reliable redirect to /console/login
- Add immediate redirection to /console/login when unauthenticated in AdminLayout
- Add timeout (4000ms) with AbortController in adminApi.ts to prevent infinite pending fetch
- Provide explicit sign-in link in layout fallback view
2026-08-25 14:42:26 +09:00
31 changed files with 1054 additions and 665 deletions
Vendored
BIN
View File
Binary file not shown.
+2
View File
@@ -25,3 +25,5 @@
/.mam-skill-backup.*/ /.mam-skill-backup.*/
CURRENT_JOB.md CURRENT_JOB.md
# <<< MAM managed block <<< # <<< MAM managed block <<<
.env
docker-compose.yaml
+2 -2
View File
@@ -29,7 +29,7 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
} }
defer rows.Close() defer rows.Close()
var projects []models.ResearchProject projects := make([]models.ResearchProject, 0)
for rows.Next() { for rows.Next() {
var p models.ResearchProject var p models.ResearchProject
var kwJSON string var kwJSON string
@@ -115,7 +115,7 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
} }
defer rows.Close() defer rows.Close()
var areas []models.ResearchArea areas := make([]models.ResearchArea, 0)
for rows.Next() { for rows.Next() {
var a models.ResearchArea var a models.ResearchArea
if err := rows.Scan(&a.ID, &a.NameEn, &a.NameKr, &a.DisplayOrder); err != nil { if err := rows.Scan(&a.ID, &a.NameEn, &a.NameKr, &a.DisplayOrder); err != nil {
+1 -1
View File
@@ -28,7 +28,7 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
} }
defer semRows.Close() defer semRows.Close()
var semesters []models.SemesterWithCourses semesters := make([]models.SemesterWithCourses, 0)
semMap := make(map[int]int) semMap := make(map[int]int)
for semRows.Next() { for semRows.Next() {
+2 -2
View File
@@ -28,7 +28,7 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
} }
defer rows.Close() defer rows.Close()
var members []models.Member members := make([]models.Member, 0)
for rows.Next() { for rows.Next() {
var m models.Member var m models.Member
if err := rows.Scan( if err := rows.Scan(
@@ -94,7 +94,7 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
} }
defer rows.Close() defer rows.Close()
var alumni []models.Alumnus alumni := make([]models.Alumnus, 0)
for rows.Next() { for rows.Next() {
var a models.Alumnus var a models.Alumnus
if err := rows.Scan( if err := rows.Scan(
+3 -3
View File
@@ -34,7 +34,7 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
} }
defer rows.Close() defer rows.Close()
var pubs []models.Publication pubs := make([]models.Publication, 0)
for rows.Next() { for rows.Next() {
var p models.Publication var p models.Publication
if err := rows.Scan( if err := rows.Scan(
@@ -101,7 +101,7 @@ func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Pu
} }
defer rows.Close() defer rows.Close()
var pubs []models.Publication pubs := make([]models.Publication, 0)
for rows.Next() { for rows.Next() {
var p models.Publication var p models.Publication
if err := rows.Scan( if err := rows.Scan(
@@ -127,7 +127,7 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
} }
defer rows.Close() defer rows.Close()
var patents []models.Patent patents := make([]models.Patent, 0)
for rows.Next() { for rows.Next() {
var p models.Patent var p models.Patent
if err := rows.Scan( if err := rows.Scan(
@@ -28,7 +28,7 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
} }
defer bodyRows.Close() defer bodyRows.Close()
var bodies []models.StandardsBodyWithProjects bodies := make([]models.StandardsBodyWithProjects, 0)
bodyMap := make(map[int]int) bodyMap := make(map[int]int)
for bodyRows.Next() { for bodyRows.Next() {
@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import Reveal from "@/components/Reveal";
import type { ResearchProject } from "../../content/types"; import type { ResearchProject } from "../../content/types";
import ProjectPageViewControls from "./ProjectPageViewControls"; import ProjectPageViewControls from "./ProjectPageViewControls";
@@ -13,22 +14,24 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
return ( return (
<section className="space-y-6"> <section className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4"> <Reveal variant="up">
<div> <div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
<div className="flex items-center gap-3 mb-1"> <div>
<span className="h-px w-8 bg-cobalt-dark"></span> <div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold"> <span className="h-px w-8 bg-cobalt-dark"></span>
Key Initiatives <span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
</span> Key Initiatives
</span>
</div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
Research Projects
</h2>
</div> </div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink"> <p className="text-xs text-ink-mute font-mono">
Research Projects {projects.length} core international standardization research projects
</h2> </p>
</div> </div>
<p className="text-xs text-ink-mute font-mono"> </Reveal>
{projects.length} core international standardization research projects
</p>
</div>
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */} {/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
<div <div
@@ -40,13 +43,16 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-cobalt/50 rounded" className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-cobalt/50 rounded"
> >
{projects.map((proj, idx) => ( {projects.map((proj, idx) => (
<article <Reveal
key={proj.slug} key={proj.slug}
as="article"
id={`project-${proj.slug}`} id={`project-${proj.slug}`}
role="group" role="group"
aria-roledescription="slide" aria-roledescription="slide"
aria-label={`${projects.length}개 중 ${idx + 1}번째 프로젝트`} aria-label={`${projects.length}개 중 ${idx + 1}번째 프로젝트`}
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-cobalt/60 transition-colors space-y-5 break-words [word-break:keep-all]" variant="up"
delay={Math.min(idx, 6) * 60}
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-cobalt/60 transition-colors space-y-5 break-words [word-break:keep-all] h-full"
> >
<div className="space-y-4"> <div className="space-y-4">
{/* Header Info */} {/* Header Info */}
@@ -124,7 +130,7 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
</div> </div>
)} )}
</div> </div>
</article> </Reveal>
))} ))}
</div> </div>
@@ -25,12 +25,12 @@ 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>} {action && <div className="flex items-center gap-3 shrink-0">{action}</div>}
</div> </div>
); );
} }
@@ -0,0 +1,86 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { setClientToken } from "./AdminComponents";
import { adminVerifyToken } from "../../../lib/adminApi";
import ThemeToggle from "@/components/ThemeToggle";
export function AdminLoginForm({ onLoginSuccess }: { onLoginSuccess?: () => void }) {
const router = useRouter();
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
setError("Please enter the admin token.");
return;
}
setIsSubmitting(true);
setError(null);
try {
const isValid = await adminVerifyToken(token.trim());
if (isValid) {
setClientToken(token.trim());
if (onLoginSuccess) {
onLoginSuccess();
} else {
router.push("/console");
}
} else {
setError("Invalid admin bearer token. Please check your credentials.");
}
} catch (err: any) {
setError(err?.message || "Failed to verify token with backend API.");
} finally {
setIsSubmitting(false);
}
};
return (
<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">
<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>
</div>
{error && (
<div className="p-3 mb-4 text-sm rounded bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="token" className="block text-sm font-medium text-ink mb-1">
Admin Bearer Token
</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="e.g. admin123"
className="w-full px-3 py-2 border border-line rounded-md bg-ivory text-ink focus:outline-none focus:ring-2 focus:ring-cobalt/50"
required
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
{isSubmitting ? "Verifying..." : "Sign In to Console"}
</button>
</form>
</div>
);
}
+62 -29
View File
@@ -4,7 +4,9 @@ import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { getClientToken, removeClientToken } from "./_components/AdminComponents"; import { getClientToken, removeClientToken } from "./_components/AdminComponents";
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();
@@ -13,14 +15,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
async function checkAuth() { async function checkAuth() {
const token = getClientToken(); const token = getClientToken();
if (!token) { if (!token) {
if (pathname !== "/console/login") { if (isMounted) setIsAuthenticated(false);
router.push("/console/login");
} else {
setIsAuthenticated(false);
}
return; return;
} }
@@ -29,11 +28,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (!isValid) { if (!isValid) {
removeClientToken(); removeClientToken();
if (pathname !== "/console/login") { setIsAuthenticated(false);
router.push("/console/login");
} else {
setIsAuthenticated(false);
}
} else { } else {
setIsAuthenticated(true); setIsAuthenticated(true);
} }
@@ -43,20 +38,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [pathname, router]); }, [pathname]);
if (pathname === "/console/login") { if (pathname === "/console/login") {
return <div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">{children}</div>; return (
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
<AdminLoginForm
onLoginSuccess={() => {
setIsAuthenticated(true);
router.push("/console");
}}
/>
</div>
);
} }
if (isAuthenticated === null) { if (isAuthenticated === null) {
const token = typeof window !== "undefined" ? getClientToken() : null;
if (!token) {
return (
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
<AdminLoginForm onLoginSuccess={() => setIsAuthenticated(true)} />
</div>
);
}
return ( return (
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center"> <div className="min-h-screen bg-ivory text-ink flex flex-col items-center justify-center space-y-4">
<div className="text-sm text-ink/70">Checking authentication...</div> <div className="text-sm text-ink/70">Checking authentication...</div>
</div> </div>
); );
} }
if (isAuthenticated === false) {
return (
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
<AdminLoginForm onLoginSuccess={() => setIsAuthenticated(true)} />
</div>
);
}
const navItems = [ const navItems = [
{ href: "/console", label: "Dashboard Overview" }, { href: "/console", label: "Dashboard Overview" },
{ href: "/console/research-projects", label: "Research Projects" }, { href: "/console/research-projects", label: "Research Projects" },
@@ -69,32 +89,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const handleLogout = () => { const handleLogout = () => {
removeClientToken(); removeClientToken();
router.push("/console/login"); setIsAuthenticated(false);
}; };
return ( return (
<div className="min-h-screen bg-ivory text-ink flex flex-col md:flex-row"> <div className="min-h-screen bg-ivory text-ink flex flex-col md:flex-row">
{/* Sidebar */} {/* Fixed Sidebar */}
<aside className="w-full md:w-64 bg-paper border-r border-line flex flex-col shrink-0"> <aside className="w-full md:w-64 md:fixed md:top-0 md:left-0 md:bottom-0 md:h-screen bg-paper border-r border-line flex flex-col shrink-0 z-30 overflow-y-auto">
<div className="p-6 border-b border-line"> <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> <div>
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span> <span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
<p className="text-xs text-ink/60 mt-0.5">Lab Data Management</p>
</div>
<div className="flex flex-col items-end gap-1.5">
<ThemeToggle />
<span className="text-[10px] bg-cobalt/10 text-cobalt font-medium px-1.5 py-0.5 rounded leading-none">
Admin
</span>
</div>
</div> </div>
<p className="text-xs text-ink/60 mt-1">Lab Data Management</p>
</div> </div>
<nav className="p-4 space-y-1 flex-1"> <nav className="p-4 space-y-1.5 flex-1 overflow-y-auto">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href; const cleanPath = (pathname || "").replace(/\/+$/, "") || "/";
const cleanHref = item.href.replace(/\/+$/, "");
const isActive =
cleanHref === "/console"
? cleanPath === "/console"
: cleanPath === cleanHref || cleanPath.startsWith(`${cleanHref}/`);
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`block px-3 py-2 text-sm font-medium rounded-md transition-colors ${ className={`block px-3 py-2 text-sm font-medium rounded-md transition-all duration-150 ${
isActive isActive
? "bg-cobalt text-white" ? "bg-cobalt text-white shadow-sm font-semibold"
: "text-ink/80 hover:bg-ivory hover:text-ink" : "text-ink/80 hover:bg-cobalt/10 hover:text-cobalt dark:hover:bg-cobalt/20 dark:hover:text-cobalt-light"
}`} }`}
> >
{item.label} {item.label}
@@ -103,10 +136,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
})} })}
</nav> </nav>
<div className="p-4 border-t border-line space-y-2"> <div className="p-4 border-t border-line space-y-2 shrink-0 bg-paper">
<Link <Link
href="/" href="/"
className="block w-full text-center px-3 py-2 text-xs font-medium rounded border border-line hover:bg-ivory text-ink/80" className="block w-full text-center px-3 py-2 text-xs font-medium rounded border border-line hover:border-cobalt/40 hover:bg-cobalt/10 hover:text-cobalt text-ink/80 transition-colors"
> >
&larr; Return to Public Site &larr; Return to Public Site
</Link> </Link>
@@ -119,8 +152,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div> </div>
</aside> </aside>
{/* Main Content Area */} {/* Main Content Area with offset for fixed sidebar */}
<main className="flex-1 p-6 md:p-10 max-w-7xl overflow-x-auto">{children}</main> <main className="flex-1 md:ml-64 p-6 md:p-10 min-h-screen overflow-x-auto">{children}</main>
</div> </div>
); );
} }
+3 -73
View File
@@ -1,78 +1,8 @@
"use client"; "use client";
import React, { useState } from "react"; import React from "react";
import { useRouter } from "next/navigation"; import { AdminLoginForm } from "../_components/AdminLoginForm";
import { setClientToken } from "../_components/AdminComponents";
import { adminVerifyToken } from "../../../lib/adminApi";
export default function AdminLoginPage() { export default function AdminLoginPage() {
const router = useRouter(); return <AdminLoginForm />;
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
setError("Please enter the admin token.");
return;
}
setIsSubmitting(true);
setError(null);
try {
const isValid = await adminVerifyToken(token.trim());
if (isValid) {
setClientToken(token.trim());
router.push("/console");
} else {
setError("Invalid admin bearer token. Please check your credentials.");
}
} catch (err: any) {
setError(err?.message || "Failed to verify token with backend API.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="w-full max-w-md bg-paper border border-line rounded-lg shadow-lg p-8">
<div className="text-center mb-6">
<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>
</div>
{error && (
<div className="p-3 mb-4 text-sm rounded bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="token" className="block text-sm font-medium text-ink mb-1">
Admin Bearer Token
</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="e.g. admin123"
className="w-full px-3 py-2 border border-line rounded-md bg-ivory text-ink focus:outline-none focus:ring-2 focus:ring-cobalt/50"
required
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
{isSubmitting ? "Verifying..." : "Sign In to Console"}
</button>
</form>
</div>
);
} }
+21 -18
View File
@@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AdminHeader, AlertBanner } from "./_components/AdminComponents"; import { AdminHeader, AlertBanner } from "./_components/AdminComponents";
import { import {
adminGetStatsSummary,
adminGetResearchProjects, adminGetResearchProjects,
adminGetResearchAreas, adminGetResearchAreas,
adminGetMembers, adminGetMembers,
@@ -13,11 +12,9 @@ import {
adminGetPatents, adminGetPatents,
adminGetSemesters, adminGetSemesters,
adminGetStandardsBodies, adminGetStandardsBodies,
AdminStatsSummary,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStatsSummary | null>(null);
const [counts, setCounts] = useState<{ const [counts, setCounts] = useState<{
projects: number; projects: number;
areas: number; areas: number;
@@ -51,7 +48,6 @@ export default function AdminDashboardPage() {
try { try {
setIsLoading(true); setIsLoading(true);
const [ const [
statsData,
projects, projects,
areas, areas,
members, members,
@@ -62,7 +58,6 @@ export default function AdminDashboardPage() {
semesters, semesters,
bodies, bodies,
] = await Promise.all([ ] = await Promise.all([
adminGetStatsSummary().catch(() => null),
adminGetResearchProjects().catch(() => []), adminGetResearchProjects().catch(() => []),
adminGetResearchAreas().catch(() => []), adminGetResearchAreas().catch(() => []),
adminGetMembers().catch(() => []), adminGetMembers().catch(() => []),
@@ -74,25 +69,33 @@ export default function AdminDashboardPage() {
adminGetStandardsBodies().catch(() => []), adminGetStandardsBodies().catch(() => []),
]); ]);
if (statsData) setStats(statsData); const safeProjects = projects || [];
const safeAreas = areas || [];
const safeMembers = members || [];
const safeAlumni = alumni || [];
const safeIntlPubs = intlPubs || [];
const safeDomPubs = domPubs || [];
const safePatents = patents || [];
const safeSemesters = semesters || [];
const safeBodies = bodies || [];
const totalCourses = semesters.reduce((acc, s) => acc + (s.courses?.length || 0), 0); const totalCourses = safeSemesters.reduce((acc, s) => acc + (s?.courses?.length || 0), 0);
const totalDocs = bodies.reduce( const totalDocs = safeBodies.reduce(
(acc, b) => acc + (b.projects?.reduce((pAcc, p) => pAcc + (p.documents?.length || 0), 0) || 0), (acc, b) => acc + ((b?.projects || []).reduce((pAcc, p) => pAcc + (p?.documents?.length || 0), 0) || 0),
0 0
); );
setCounts({ setCounts({
projects: projects.length, projects: safeProjects.length,
areas: areas.length, areas: safeAreas.length,
members: members.length, members: safeMembers.length,
alumni: alumni.length, alumni: safeAlumni.length,
intlPubs: intlPubs.length, intlPubs: safeIntlPubs.length,
domPubs: domPubs.length, domPubs: safeDomPubs.length,
patents: patents.length, patents: safePatents.length,
semesters: semesters.length, semesters: safeSemesters.length,
courses: totalCourses, courses: totalCourses,
bodies: bodies.length, bodies: safeBodies.length,
docs: totalDocs, docs: totalDocs,
}); });
} catch (err: any) { } catch (err: any) {
@@ -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,66 @@ 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">
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length}) All Categories ({publications.filter((p) => filterYear === "all" || (p.published_at || "").includes(filterYear)).length})
</option> </option>
<option value="domestic-journal-conf"> <option value="intl-journal-conf">
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length}) International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
</option> </option>
</select> <option value="domestic-journal-conf">
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
</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.filter((p) => filterCategory === "all" || p.category === filterCategory).length})
</option>
{pubYears.map((yr) => {
const count = publications.filter((p) => {
const matchCat = filterCategory === "all" || p.category === filterCategory;
const matchYr = (p.published_at || "").includes(yr);
return matchCat && matchYr;
}).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 +460,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 +552,7 @@ export default function AdminPublicationsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
{/* Pub Modal */} {/* Pub Modal */}
+96 -81
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Reveal from "@/components/Reveal";
import { getSemesters } from "../../lib/api"; import { getSemesters } from "../../lib/api";
import type { Semester } from "../../content/types"; import type { Semester } from "../../content/types";
@@ -23,110 +24,124 @@ export default function LecturesPage() {
<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 */}
<section> <section>
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight"> <Reveal variant="up">
LECTURES <h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
</h1> LECTURES
</h1>
</Reveal>
</section> </section>
{/* Recent 3 Semesters */} {/* Recent 3 Semesters */}
<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"> <Reveal variant="up">
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark"> <div className="flex justify-between items-center">
Recent Courses <h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
</h2> Recent Courses
<span className="font-mono text-xs text-ink-mute hidden sm:inline"> </h2>
Latest 3 Semesters <span className="font-mono text-xs text-ink-mute hidden sm:inline">
</span> Latest 3 Semesters
</div> </span>
</div>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-6">
{recentSemesters.map((sem, idx) => ( {recentSemesters.map((sem, idx) => (
<div <Reveal
key={idx} key={idx}
className="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm" variant="up"
delay={idx * 80}
className="h-full"
> >
<div className="space-y-3"> <div
<div className="flex justify-between items-center border-b border-line pb-2.5"> className="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm h-full"
<span className="font-serif text-lg sm:text-xl font-normal text-ink"> >
{sem.term} {sem.year} <div className="space-y-3">
</span> <div className="flex justify-between items-center border-b border-line pb-2.5">
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10"> <span className="font-serif text-lg sm:text-xl font-normal text-ink">
Latest {sem.term} {sem.year}
</span> </span>
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10">
Latest
</span>
</div>
<ul className="space-y-2.5 pt-1">
{sem.courses.map((c, cIdx) => (
<li key={cIdx} className="space-y-0.5">
<div className="font-serif text-sm sm:text-base text-ink leading-snug">
{c.en}
</div>
<div className="font-mono text-xs text-ink-mute">
Code: {c.code} {c.note && `(${c.note})`}
</div>
</li>
))}
</ul>
</div> </div>
<ul className="space-y-2.5 pt-1"> <div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
{sem.courses.map((c, cIdx) => ( <span>Total Offerings</span>
<li key={cIdx} className="space-y-0.5"> <span className="font-semibold text-ink">{sem.courses.length} Courses</span>
<div className="font-serif text-sm sm:text-base text-ink leading-snug"> </div>
{c.en}
</div>
<div className="font-mono text-xs text-ink-mute">
Code: {c.code} {c.note && `(${c.note})`}
</div>
</li>
))}
</ul>
</div> </div>
</Reveal>
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
<span>Total Offerings</span>
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
</div>
</div>
))} ))}
</div> </div>
</section> </section>
{/* Older Semesters (Native <details>/<summary> progressive disclosure) */} {/* Older Semesters (Native <details>/<summary> progressive disclosure) */}
<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">
<details className="group border border-line rounded-lg bg-paper p-5 sm:p-6 transition-all duration-200"> <Reveal variant="up">
<summary className="flex justify-between items-center cursor-pointer list-none select-none"> <div className="border border-line rounded-lg bg-paper p-5 sm:p-6 transition-all duration-200">
<div className="space-y-1"> <details className="group">
<span className="font-serif text-lg sm:text-xl font-normal text-ink"> <summary className="flex justify-between items-center cursor-pointer list-none select-none">
Browse all semesters <div className="space-y-1">
</span> <span className="font-serif text-lg sm:text-xl font-normal text-ink">
<p className="text-xs text-ink-mute font-mono"> Browse all semesters
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
</p>
</div>
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
<span className="group-open:hidden">Open </span>
<span className="hidden group-open:inline">Close </span>
</span>
</summary>
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
{olderSemesters.map((sem, idx) => (
<div
key={idx}
className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
>
<div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
<span>
{sem.term} {sem.year}
</span>
<span className="font-mono text-xs text-ink-mute">
{sem.courses.length} Courses
</span> </span>
<p className="text-xs text-ink-mute font-mono">
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
</p>
</div> </div>
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
<span className="group-open:hidden">Open </span>
<span className="hidden group-open:inline">Close </span>
</span>
</summary>
<ul className="space-y-2"> <div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
{sem.courses.map((c, cIdx) => ( {olderSemesters.map((sem, idx) => (
<li key={cIdx} className="space-y-0.5"> <div
<div className="font-serif text-sm text-ink leading-snug"> key={idx}
{c.en} className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
</div> >
<div className="font-mono text-[0.7rem] text-ink-mute"> <div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
Code: {c.code} {c.note && `(${c.note})`} <span>
</div> {sem.term} {sem.year}
</li> </span>
))} <span className="font-mono text-xs text-ink-mute">
</ul> {sem.courses.length} Courses
</span>
</div>
<ul className="space-y-2">
{sem.courses.map((c, cIdx) => (
<li key={cIdx} className="space-y-0.5">
<div className="font-serif text-sm text-ink leading-snug">
{c.en}
</div>
<div className="font-mono text-[0.7rem] text-ink-mute">
Code: {c.code} {c.note && `(${c.note})`}
</div>
</li>
))}
</ul>
</div>
))}
</div> </div>
))} </details>
</div> </div>
</details> </Reveal>
</section> </section>
</div> </div>
); );
+119 -89
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Image from "next/image";
import Reveal from "@/components/Reveal";
import { getMembers, getAlumni } from "../../lib/api"; import { getMembers, getAlumni } from "../../lib/api";
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog"; import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
import type { Member, Alumnus } from "../../content/types"; import type { Member, Alumnus } from "../../content/types";
@@ -42,121 +44,147 @@ export default function MembersPage() {
<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 */}
<section> <section>
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight"> <Reveal variant="up">
LAB MEMBERS <h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
</h1> LAB MEMBERS
</h1>
</Reveal>
</section> </section>
{/* Professor Section */} {/* Professor 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="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center"> <Reveal variant="up">
<div className="lg:col-span-8 space-y-4"> <div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
<h2 className="font-serif text-3xl font-normal text-ink"> <div className="lg:col-span-8 space-y-4">
Prof. Seok-Joo Koh <h2 className="font-serif text-3xl font-normal text-ink">
</h2> Prof. Seok-Joo Koh
<div className="text-sm text-ink-mute leading-relaxed space-y-2"> </h2>
<p> <div className="text-sm text-ink-mute leading-relaxed space-y-2">
Professor at the School of Computer Science and Engineering, Kyungpook National University, and Dean of the College of IT Engineering. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP &amp; SKILL architectures, and multi-agent orchestration, and serves as Project Editor in ISO/IEC JTC1, ITU-T, and IEC TC100. <p>
</p> Professor at the School of Computer Science and Engineering, Kyungpook National University, and Dean of the College of IT Engineering. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP &amp; SKILL architectures, and multi-agent orchestration, and serves as Project Editor in ISO/IEC JTC1, ITU-T, and IEC TC100.
<div> </p>
<ProfessorDetailsDialog /> <div>
<ProfessorDetailsDialog />
</div>
</div>
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono">
<a
href="mailto:sjkoh@knu.ac.kr"
className="text-cobalt-dark hover:underline font-bold"
>
sjkoh@knu.ac.kr
</a>
<span className="text-ink-mute">|</span>
<a
href="https://github.com/sjkoh12"
target="_blank"
rel="noopener noreferrer"
className="text-ink hover:text-cobalt-dark hover:underline"
>
GitHub Profile
</a>
</div> </div>
</div> </div>
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono"> <div className="lg:col-span-4 flex justify-center items-center">
<a <div className="relative w-48 h-48 sm:w-56 sm:h-56 md:w-60 md:h-60 rounded-xl overflow-hidden border border-line bg-transparent">
href="mailto:sjkoh@knu.ac.kr" <Image
className="text-cobalt-dark hover:underline font-bold" src="/images/prof_profile.png"
> alt="Prof. Seok-Joo Koh"
sjkoh@knu.ac.kr fill
</a> sizes="(max-width: 1024px) 224px, 240px"
<span className="text-ink-mute">|</span> className="object-cover object-top"
<a priority
href="https://github.com/sjkoh12" />
target="_blank" </div>
rel="noopener noreferrer"
className="text-ink hover:text-cobalt-dark hover:underline"
>
GitHub Profile
</a>
</div> </div>
</div> </div>
<div className="lg:col-span-4 bg-ivory p-6 rounded border border-line text-xs space-y-2 font-mono"> </Reveal>
<div className="text-ink font-bold border-b border-line pb-1">
Affiliation &amp; Office
</div>
<p className="text-ink-mute">Kyungpook National University</p>
<p className="text-ink-mute">IT Building #5 Room 527</p>
<p className="text-ink-mute">Daegu, Republic of Korea</p>
</div>
</div>
</section> </section>
{/* Active Researchers Section */} {/* Active Researchers Section */}
<section className="space-y-8 pt-6 md:pt-8 border-t border-line"> <section className="space-y-8 pt-6 md:pt-8 border-t border-line">
<div className="flex justify-between items-end"> <Reveal variant="up">
<div> <div className="flex justify-between items-end">
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark"> <div>
Active Researchers <h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
</h2> Active Researchers
</h2>
</div>
</div> </div>
</div> </Reveal>
{/* Ph.D. Candidates / Students */} {/* Ph.D. Candidates / Students */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2"> <Reveal variant="up">
Ph.D. Course ({phdStudents.length}) <h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
</h3> Ph.D. Course ({phdStudents.length})
</h3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{phdStudents.map((mem, idx) => ( {phdStudents.map((mem, idx) => (
<div <Reveal
key={idx} key={idx}
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors" variant="up"
delay={Math.min(idx, 6) * 60}
className="h-full"
> >
<div className="flex justify-between items-baseline"> <div
<span className="font-serif text-lg font-normal text-ink"> className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
{mem.ko} >
</span> <div className="flex justify-between items-baseline">
<span className="font-mono text-xs text-cobalt-dark"> <span className="font-serif text-lg font-normal text-ink">
{DEGREE_LABEL[mem.degree] || mem.degree} {mem.ko}
</span> </span>
</div> <span className="font-mono text-xs text-cobalt-dark">
<div className="font-mono text-xs text-ink-mute">{mem.en}</div> {DEGREE_LABEL[mem.degree] || mem.degree}
{mem.affiliation && ( </span>
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
with {mem.affiliation}
</div> </div>
)} <div className="font-mono text-xs text-ink-mute">{mem.en}</div>
</div> {mem.affiliation && (
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
with {mem.affiliation}
</div>
)}
</div>
</Reveal>
))} ))}
</div> </div>
</div> </div>
{/* M.S. Candidates / Students */} {/* M.S. Candidates / Students */}
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2"> <Reveal variant="up">
M.S. Course ({msStudents.length}) <h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
</h3> M.S. Course ({msStudents.length})
</h3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{msStudents.map((mem, idx) => ( {msStudents.map((mem, idx) => (
<div <Reveal
key={idx} key={idx}
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors" variant="up"
delay={Math.min(idx, 6) * 60}
className="h-full"
> >
<div className="flex justify-between items-baseline"> <div
<span className="font-serif text-lg font-normal text-ink"> className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
{mem.ko} >
</span> <div className="flex justify-between items-baseline">
<span className="font-mono text-xs text-cobalt-dark"> <span className="font-serif text-lg font-normal text-ink">
{DEGREE_LABEL[mem.degree] || mem.degree} {mem.ko}
</span> </span>
</div> <span className="font-mono text-xs text-cobalt-dark">
<div className="font-mono text-xs text-ink-mute">{mem.en}</div> {DEGREE_LABEL[mem.degree] || mem.degree}
{mem.affiliation && ( </span>
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
with {mem.affiliation}
</div> </div>
)} <div className="font-mono text-xs text-ink-mute">{mem.en}</div>
</div> {mem.affiliation && (
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
with {mem.affiliation}
</div>
)}
</div>
</Reveal>
))} ))}
</div> </div>
</div> </div>
@@ -164,13 +192,15 @@ export default function MembersPage() {
{/* Alumni Section (Native <details>/<summary> progressive disclosure) */} {/* Alumni Section (Native <details>/<summary> progressive disclosure) */}
<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-end"> <Reveal variant="up">
<div> <div className="flex justify-between items-end">
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink"> <div>
Graduates <h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
</h2> Graduates
</h2>
</div>
</div> </div>
</div> </Reveal>
<details className="group border border-line rounded-lg bg-paper p-6 transition-all duration-200"> <details className="group border border-line rounded-lg bg-paper p-6 transition-all duration-200">
<summary className="flex justify-between items-center cursor-pointer list-none select-none"> <summary className="flex justify-between items-center cursor-pointer list-none select-none">
+125 -101
View File
@@ -2,6 +2,8 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Reveal from "@/components/Reveal";
import Counter from "@/components/Counter";
import HeroComposition from "./_components/HeroComposition"; import HeroComposition from "./_components/HeroComposition";
import { ProjectPageView } from "./_components/ProjectPageView"; import { ProjectPageView } from "./_components/ProjectPageView";
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api"; import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
@@ -40,57 +42,67 @@ export default function HomePage() {
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24"> <div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
{/* Hero Section */} {/* Hero Section */}
<section className="relative"> <section className="relative">
<div className="mb-4"> <Reveal variant="up">
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold"> <div className="mb-4">
Kyungpook National University · AI Agent Networking Lab (ANL) <span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
</span> Kyungpook National University · AI Agent Networking Lab (ANL)
</div> </span>
</div>
</Reveal>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:items-center"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:items-center">
<div className="lg:col-span-7 space-y-6 lg:pt-4"> <div className="lg:col-span-7 space-y-6 lg:pt-4">
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight"> <Reveal variant="up">
Multi-Agent <br /> <h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
<span className="italic font-serif text-cobalt-dark">Orchestration</span> Multi-Agent <br />
</h1> <span className="italic font-serif text-cobalt-dark">Orchestration</span>
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl"> </h1>
The AI Agent Networking Laboratory (ANL) studies agent networking technologies and standard frameworks that enable AI agents to communicate and collaborate with one another for multi-agent orchestration. <p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl mt-4">
</p> The AI Agent Networking Laboratory (ANL) studies agent networking technologies and standard frameworks that enable AI agents to communicate and collaborate with one another for multi-agent orchestration.
</p>
</Reveal>
{/* Metrics Row (Derived from real content data) */} {/* Metrics Row (Derived from real content data) */}
<div className="pt-6 border-t border-line grid grid-cols-3 gap-4"> <div className="pt-6 border-t border-line grid grid-cols-3 gap-4">
<div> <Reveal variant="up" delay={80}>
<div className="font-serif text-3xl text-cobalt-dark font-normal"> <div>
{stats.intlPubsCount} <div className="font-serif text-3xl text-cobalt-dark font-normal">
<Counter value={stats.intlPubsCount} />
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
International Journals &amp; Conference
</div>
</div> </div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight"> </Reveal>
International Journals &amp; Conference <Reveal variant="up" delay={160}>
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
<Counter value={stats.stdDocsCount} />
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Standardization Contributions
</div>
</div> </div>
</div> </Reveal>
<div> <Reveal variant="up" delay={240}>
<div className="font-serif text-3xl text-cobalt-dark font-normal"> <div>
{stats.stdDocsCount} <div className="font-serif text-3xl text-cobalt-dark font-normal">
<Counter value={stats.patentCount} />
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Patent<br />
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
(IoT Architecture &amp; Protocols)
</span>
</div>
</div> </div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight"> </Reveal>
Standardization Contributions
</div>
</div>
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{stats.patentCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Patent<br />
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
(IoT Architecture &amp; Protocols)
</span>
</div>
</div>
</div> </div>
</div> </div>
<div className="w-full max-w-[500px] mx-auto my-[30px] lg:my-0 lg:max-w-none lg:mx-0 lg:col-span-5 border border-line bg-paper p-4 rounded-lg"> <Reveal variant="scale" delay={120} className="w-full max-w-[500px] mx-auto my-[30px] lg:my-0 lg:max-w-none lg:mx-0 lg:col-span-5 border border-line bg-paper p-4 rounded-lg">
<HeroComposition /> <HeroComposition />
</div> </Reveal>
</div> </div>
</section> </section>
@@ -99,22 +111,24 @@ export default function HomePage() {
{/* 14 Research Areas Grid */} {/* 14 Research Areas Grid */}
<section className="space-y-8 pt-16 md:pt-20 border-t border-line"> <section className="space-y-8 pt-16 md:pt-20 border-t border-line">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4"> <Reveal variant="up">
<div> <div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
<div className="flex items-center gap-3 mb-1"> <div>
<span className="h-px w-8 bg-cobalt-dark"></span> <div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold"> <span className="h-px w-8 bg-cobalt-dark"></span>
Core Domains <span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
</span> Core Domains
</span>
</div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
Research Areas
</h2>
</div> </div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink"> <p className="text-xs text-ink-mute font-mono">
Research Areas {researchAreas.length} fundamental networking protocols and AI system domains
</h2> </p>
</div> </div>
<p className="text-xs text-ink-mute font-mono"> </Reveal>
{researchAreas.length} fundamental networking protocols and AI system domains
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{researchAreas.map((area, idx) => { {researchAreas.map((area, idx) => {
@@ -122,28 +136,34 @@ export default function HomePage() {
const isOdd = (idx + 1) % 2 !== 0; const isOdd = (idx + 1) % 2 !== 0;
return ( return (
<div <Reveal
key={no} key={no}
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full" variant="up"
delay={Math.min(idx, 6) * 60}
className="h-full"
> >
<div className="space-y-3"> <div
<div className="flex justify-between items-center"> className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
<span >
className={`font-mono text-xs font-bold ${ <div className="space-y-3">
isOdd ? "text-vermillion-dark" : "text-cobalt-dark" <div className="flex justify-between items-center">
}`} <span
> className={`font-mono text-xs font-bold ${
{no} isOdd ? "text-vermillion-dark" : "text-cobalt-dark"
</span> }`}
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider"> >
Topic #{no} {no}
</span> </span>
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider">
Topic #{no}
</span>
</div>
<h3 className="font-serif text-base font-normal text-ink leading-snug">
{area}
</h3>
</div> </div>
<h3 className="font-serif text-base font-normal text-ink leading-snug">
{area}
</h3>
</div> </div>
</div> </Reveal>
); );
})} })}
</div> </div>
@@ -151,43 +171,47 @@ export default function HomePage() {
{/* International Standardizations Section */} {/* International Standardizations Section */}
<section className="space-y-6 pt-16 md:pt-20 border-t border-line"> <section className="space-y-6 pt-16 md:pt-20 border-t border-line">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4"> <Reveal variant="up">
<div> <div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
<div className="flex items-center gap-3 mb-1"> <div>
<span className="h-px w-8 bg-cobalt-dark"></span> <div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold"> <span className="h-px w-8 bg-cobalt-dark"></span>
Global Engagement <span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
</span> Global Engagement
</span>
</div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
International Standardizations
</h2>
</div> </div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
International Standardizations
</h2>
</div> </div>
</div> </Reveal>
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6"> <Reveal variant="up">
<p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl"> <div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6">
Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations): <p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl">
</p> Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations):
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2"> <ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2">
{INTERNATIONAL_SDOS.map((sdo, idx) => ( {INTERNATIONAL_SDOS.map((sdo, idx) => (
<li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans"> <li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span> <span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
<span>{sdo}</span> <span>{sdo}</span>
</li> </li>
))} ))}
</ul> </ul>
<div className="pt-4 border-t border-line/60"> <div className="pt-4 border-t border-line/60">
<Link <Link
href="/standardization" href="/standardization"
className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline" className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline"
> >
<span>Please refer to here for details </span> <span>Please refer to here for details </span>
</Link> </Link>
</div>
</div> </div>
</div> </Reveal>
</section> </section>
</div> </div>
); );
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Reveal from "@/components/Reveal";
import { CategoryNav } from "../../_components/CategoryNav"; import { CategoryNav } from "../../_components/CategoryNav";
import { getPublications, getPatents } from "../../../../lib/api"; import { getPublications, getPatents } from "../../../../lib/api";
import { PUB_CATEGORIES } from "../../../../content/categories"; import { PUB_CATEGORIES } from "../../../../content/categories";
@@ -18,6 +19,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 +36,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 +50,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,18 +62,41 @@ export function PublicationCategoryClient({ category }: { category: string }) {
})); }));
} }
// Extract distinct available years from date strings (YYYY-MM or YYYY)
const availableYears = Array.from(
new Set(
allRecords
.map((r) => {
const dateStr = r.publishedAt || "";
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));
// Filter records by selected year
const records = allRecords.filter((r) => {
if (selectedYear === "all") return true;
const dateStr = r.publishedAt || "";
const yr = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
return yr === 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 */}
<section className="space-y-1"> <section className="space-y-1">
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight"> <Reveal variant="up">
{currentCat.label} <h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
</h1> {currentCat.label}
{currentCat.subtitle && ( </h1>
<p className="font-mono text-sm text-ink-mute"> {currentCat.subtitle && (
{currentCat.subtitle} <p className="font-mono text-sm text-ink-mute mt-1">
</p> {currentCat.subtitle}
)} </p>
)}
</Reveal>
</section> </section>
{/* Category Nav Cards */} {/* Category Nav Cards */}
@@ -80,11 +106,40 @@ 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"> <Reveal variant="up">
<span className="font-mono text-xs text-ink-mute uppercase"> <div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
{records.length} Records <div className="flex items-center gap-2">
</span> <span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
</div> <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">
Showing {records.length} of {allRecords.length} Records
</span>
</div>
</Reveal>
{records.length === 0 ? ( {records.length === 0 ? (
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3"> <div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
@@ -1,5 +1,7 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import Reveal from "@/components/Reveal";
import Counter from "@/components/Counter";
import { PUB_CATEGORIES } from "../../../content/categories"; import { PUB_CATEGORIES } from "../../../content/categories";
import { PubCategory } from "../../../content/types"; import { PubCategory } from "../../../content/types";
@@ -24,87 +26,95 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <Reveal variant="up">
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark"> <div className="flex justify-between items-center">
Categories <h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
</h2> Categories
{currentCategory && ( </h2>
<Link {currentCategory && (
href="/publications" <Link
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors" href="/publications"
> className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
&larr; Overview >
</Link> &larr; Overview
)} </Link>
</div> )}
</div>
</Reveal>
<div className="grid gap-5 sm:gap-6 md:grid-cols-3"> <div className="grid gap-5 sm:gap-6 md:grid-cols-3">
{PUB_CATEGORIES.map((cat) => { {PUB_CATEGORIES.map((cat, idx) => {
const count = getCount(cat.slug); const count = getCount(cat.slug);
const isActive = currentCategory === cat.slug; const isActive = currentCategory === cat.slug;
return ( return (
<div <Reveal
key={cat.slug} key={cat.slug}
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all ${ variant="up"
isActive delay={idx * 80}
? "border-2 border-cobalt-dark shadow-md" className="h-full"
: "border border-line shadow-sm hover:border-cobalt-dark/40"
}`}
> >
<div className="space-y-3"> <div
<div className="flex justify-between items-center"> className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all h-full ${
<span isActive
className={`font-mono text-xs font-bold uppercase tracking-wider ${ ? "border-2 border-cobalt-dark shadow-md"
isActive ? "text-cobalt-dark" : "text-ink-mute" : "border border-line shadow-sm hover:border-cobalt-dark/40"
}`}
>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span
className={`font-mono text-xs font-bold uppercase tracking-wider ${
isActive ? "text-cobalt-dark" : "text-ink-mute"
}`}
>
Category
</span>
{isActive && (
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
Current
</span>
)}
</div>
<h3
className={`font-serif text-xl ${
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
}`} }`}
> >
Category {cat.label}
</span> {cat.subtitle && (
{isActive && ( <span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase"> {cat.subtitle}
Current </span>
</span> )}
)} </h3>
</div> </div>
<h3 <div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
className={`font-serif text-xl ${ <span className="font-display text-3xl font-bold text-ink">
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal" <Counter value={count} />
}`} <span className="text-xs font-mono text-ink-mute ml-1 font-normal">
> {cat.slug === "patent" ? "Patents" : "Papers"}
{cat.label}
{cat.subtitle && (
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
{cat.subtitle}
</span> </span>
</span>
{isActive ? (
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
Active View
</span>
) : (
<Link
href={`/publications/${cat.slug}`}
aria-label={`View all ${cat.label} records`}
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
>
View All &rarr;
</Link>
)} )}
</h3> </div>
</div> </div>
</Reveal>
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
<span className="font-display text-3xl font-bold text-ink">
{count}
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
{cat.slug === "patent" ? "Patents" : "Papers"}
</span>
</span>
{isActive ? (
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
Active View
</span>
) : (
<Link
href={`/publications/${cat.slug}`}
aria-label={`View all ${cat.label} records`}
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
>
View All &rarr;
</Link>
)}
</div>
</div>
); );
})} })}
</div> </div>
+19 -8
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Reveal from "@/components/Reveal";
import { CategoryNav } from "./_components/CategoryNav"; import { CategoryNav } from "./_components/CategoryNav";
import { getPublications, getPatents } from "../../lib/api"; import { getPublications, getPatents } from "../../lib/api";
import type { Publication, Patent } from "../../content/types"; import type { Publication, Patent } from "../../content/types";
@@ -31,9 +32,11 @@ export default function PublicationsIndexPage() {
<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 */}
<section> <section>
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight"> <Reveal variant="up">
PUBLICATIONS <h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
</h1> PUBLICATIONS
</h1>
</Reveal>
</section> </section>
{/* Category Summary Cards */} {/* Category Summary Cards */}
@@ -43,13 +46,21 @@ export default function PublicationsIndexPage() {
{/* Highlights Section */} {/* Highlights 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">
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark"> <Reveal variant="up">
Highlights <h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
</h2> Highlights
</h2>
</Reveal>
<div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm"> <div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm">
{highlights.map((pub, idx) => ( {highlights.map((pub, idx) => (
<article key={`${pub.category}-${idx}`} className="p-5 hover:bg-ivory/60 transition-colors space-y-2"> <Reveal
key={`${pub.category}-${idx}`}
as="article"
variant="up"
delay={Math.min(idx, 6) * 60}
className="p-5 hover:bg-ivory/60 transition-colors space-y-2"
>
<div className="flex flex-wrap items-center gap-2 text-xs font-mono text-ink-mute"> <div className="flex flex-wrap items-center gap-2 text-xs font-mono text-ink-mute">
<span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold"> <span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold">
{pub.publishedAt?.substring(0, 7) || "Recent"} {pub.publishedAt?.substring(0, 7) || "Recent"}
@@ -76,7 +87,7 @@ export default function PublicationsIndexPage() {
{pub.venue}{pub.volume ? `, ${pub.volume}` : ""} {pub.venue}{pub.volume ? `, ${pub.volume}` : ""}
</p> </p>
)} )}
</article> </Reveal>
))} ))}
</div> </div>
</section> </section>
+88 -79
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Reveal from "@/components/Reveal";
import { getStandardsBodies } from "../../lib/api"; import { getStandardsBodies } from "../../lib/api";
import type { StandardsBody } from "../../content/types"; import type { StandardsBody } from "../../content/types";
@@ -16,95 +17,103 @@ export default function StandardizationPage() {
<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 */}
<section> <section>
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight"> <Reveal variant="up">
International Standardization <h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
</h1> International Standardization
</h1>
</Reveal>
</section> </section>
{/* Standards Bodies Section */} {/* Standards Bodies Section */}
<section className="space-y-8 sm:space-y-10 pt-6 md:pt-8 border-t border-line"> <section className="space-y-8 sm:space-y-10 pt-6 md:pt-8 border-t border-line">
{standardsBodies.map((body, idx) => ( {standardsBodies.map((body, idx) => (
<div key={idx} className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6 shadow-sm"> <Reveal
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4"> key={idx}
<div> variant="up"
<div className="flex items-center gap-2"> delay={idx * 120}
<span className="font-mono text-xs text-cobalt-dark font-bold uppercase"> >
[{body.scope}] <div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6 shadow-sm">
</span> <div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
{body.role && ( <div>
<span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold"> <div className="flex items-center gap-2">
{body.role} <span className="font-mono text-xs text-cobalt-dark font-bold uppercase">
[{body.scope}]
</span> </span>
)} {body.role && (
</div> <span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
<h2 className="font-serif text-2xl font-normal text-ink mt-1"> {body.role}
{body.org}
</h2>
<p className="text-xs text-ink-mute font-mono mt-0.5">
{body.full} ({body.period})
</p>
</div>
<div className="font-mono text-xs text-ink-mute bg-ivory border border-line px-3 py-1.5 rounded">
Projects: {body.projects.length}
</div>
</div>
{/* 3-Tier Hierarchy: Projects & Documents */}
<div className="space-y-6">
{body.projects.map((proj, pIdx) => (
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
<div>
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
{proj.wg}
</span> </span>
<span>{proj.name}</span> )}
</div>
<span className="font-mono text-xs text-ink-mute">
{proj.documents.length} Docs
</span>
</div> </div>
<h2 className="font-serif text-2xl font-normal text-ink mt-1">
{proj.documents.length > 0 ? ( {body.org}
<div className="space-y-3 pt-1"> </h2>
{proj.documents.map((doc, dIdx) => ( <p className="text-xs text-ink-mute font-mono mt-0.5">
<div {body.full} ({body.period})
key={dIdx} </p>
className="bg-paper p-4 rounded border border-line/60 space-y-1.5"
>
<div className="flex justify-between items-start gap-4">
<h3 className="font-serif text-base font-normal text-ink leading-snug">
&ldquo;{doc.title}&rdquo;
</h3>
<span
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
doc.status === "InProgress"
? "bg-vermillion-dark text-ivory"
: "bg-cobalt-dark text-ivory"
}`}
>
{doc.status}
</span>
</div>
<div className="font-mono text-xs text-ink-mute flex justify-between">
<span>{doc.ref}</span>
{doc.publishedAt && (
<span className="text-ink">{doc.publishedAt}</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-xs text-ink-mute font-mono italic">
Project specification in progress
</div>
)}
</div> </div>
))}
<div className="font-mono text-xs text-ink-mute bg-ivory border border-line px-3 py-1.5 rounded">
Projects: {body.projects.length}
</div>
</div>
{/* 3-Tier Hierarchy: Projects & Documents */}
<div className="space-y-6">
{body.projects.map((proj, pIdx) => (
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
<div>
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
{proj.wg}
</span>
<span>{proj.name}</span>
</div>
<span className="font-mono text-xs text-ink-mute">
{proj.documents.length} Docs
</span>
</div>
{proj.documents.length > 0 ? (
<div className="space-y-3 pt-1">
{proj.documents.map((doc, dIdx) => (
<div
key={dIdx}
className="bg-paper p-4 rounded border border-line/60 space-y-1.5"
>
<div className="flex justify-between items-start gap-4">
<h3 className="font-serif text-base font-normal text-ink leading-snug">
&ldquo;{doc.title}&rdquo;
</h3>
<span
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
doc.status === "InProgress"
? "bg-vermillion-dark text-ivory"
: "bg-cobalt-dark text-ivory"
}`}
>
{doc.status}
</span>
</div>
<div className="font-mono text-xs text-ink-mute flex justify-between">
<span>{doc.ref}</span>
{doc.publishedAt && (
<span className="text-ink">{doc.publishedAt}</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-xs text-ink-mute font-mono italic">
Project specification in progress
</div>
)}
</div>
))}
</div>
</div> </div>
</div> </Reveal>
))} ))}
</section> </section>
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+8
View File
@@ -1,4 +1,7 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import Marquee from "@/components/Marquee"; import Marquee from "@/components/Marquee";
// CUSTOMIZATION HOOK: replace address / email placeholders & colophon below. // CUSTOMIZATION HOOK: replace address / email placeholders & colophon below.
@@ -11,6 +14,11 @@ const links = [
]; ];
export default function Footer() { export default function Footer() {
const pathname = usePathname();
if (pathname && pathname.startsWith("/console")) {
return null;
}
return ( return (
<footer className="mt-0 border-t border-line bg-paper text-ink lg:mt-8"> <footer className="mt-0 border-t border-line bg-paper text-ink lg:mt-8">
{/* Marquee band — colophon ticker */} {/* Marquee band — colophon ticker */}
+4
View File
@@ -106,6 +106,10 @@ export default function Header() {
: pathname === href || pathname.startsWith(`${href}/`) : pathname === href || pathname.startsWith(`${href}/`)
: false; : false;
if (pathname && pathname.startsWith("/console")) {
return null;
}
return ( return (
<header <header
data-smart-sticky data-smart-sticky
+10 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, type ElementType, type ReactNode } from "react"; import { useEffect, useRef, type ElementType, type ReactNode, type HTMLAttributes } from "react";
type Variant = "up" | "left" | "right" | "scale"; type Variant = "up" | "left" | "right" | "scale";
@@ -18,7 +18,9 @@ export default function Reveal({
delay = 0, delay = 0,
as: Tag = "div", as: Tag = "div",
className = "", className = "",
}: { style,
...rest
}: HTMLAttributes<HTMLElement> & {
children: ReactNode; children: ReactNode;
variant?: Variant; variant?: Variant;
delay?: number; delay?: number;
@@ -47,12 +49,17 @@ export default function Reveal({
return () => io.disconnect(); return () => io.disconnect();
}, []); }, []);
const combinedStyle = delay
? { ...style, transitionDelay: `${delay}ms` }
: style;
return ( return (
<Tag <Tag
ref={ref} ref={ref}
data-variant={variant} data-variant={variant}
style={delay ? { transitionDelay: `${delay}ms` } : undefined} style={combinedStyle}
className={`reveal ${className}`} className={`reveal ${className}`}
{...rest}
> >
{children} {children}
</Tag> </Tag>
+46 -31
View File
@@ -3,17 +3,34 @@
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE). * Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
*/ */
export function normalizeApiUrl(rawUrl?: string): string {
let url = (rawUrl || "").trim().replace(/\/+$/, "");
if (!url || url === "/" || url === "/api" || url === "/api/v1") {
return "/api/v1";
}
if (url.endsWith("/api/v1")) {
return url;
}
if (url.endsWith("/api")) {
return `${url}/v1`;
}
return `${url}/api/v1`;
}
export function getAdminApiBaseUrl(): string { export function getAdminApiBaseUrl(): string {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// Browser / CSR context: same-origin relative path by default. // Browser / CSR context:
return process.env.NEXT_PUBLIC_API_BASE_URL || "/api/v1"; if (process.env.NEXT_PUBLIC_API_BASE_URL) {
return normalizeApiUrl(process.env.NEXT_PUBLIC_API_BASE_URL);
}
return "/api/v1";
} }
// Server / build-time context: defensive fallback during static prerendering // Server / build-time context: defensive fallback during static prerendering
return ( const raw =
process.env.API_BASE_URL || process.env.API_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL ||
"http://localhost:8080/api/v1" "http://localhost:8080/api/v1";
); return normalizeApiUrl(raw);
} }
export interface AdminResearchProject { export interface AdminResearchProject {
@@ -132,12 +149,6 @@ export interface AdminStandardsBody {
projects: AdminStandardProjectGroup[]; projects: AdminStandardProjectGroup[];
} }
export interface AdminStatsSummary {
intl_publications: number;
standardization_docs: number;
patents: number;
}
async function request<T>( async function request<T>(
endpoint: string, endpoint: string,
options: RequestInit = {}, options: RequestInit = {},
@@ -153,23 +164,32 @@ async function request<T>(
const baseUrl = getAdminApiBaseUrl(); const baseUrl = getAdminApiBaseUrl();
const url = `${baseUrl}${endpoint}`; const url = `${baseUrl}${endpoint}`;
const response = await fetch(url, {
...options, const controller = new AbortController();
headers, const timeoutId = setTimeout(() => controller.abort(), 4000);
cache: "no-store",
});
if (response.status === 204) { try {
return {} as T; const response = await fetch(url, {
...options,
headers,
signal: controller.signal,
cache: "no-store",
});
if (response.status === 204) {
return {} as T;
}
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMsg = json?.error?.message || `Request failed with status ${response.status}`;
throw new Error(errorMsg);
}
return json.data;
} finally {
clearTimeout(timeoutId);
} }
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMsg = json?.error?.message || `Request failed with status ${response.status}`;
throw new Error(errorMsg);
}
return json.data;
} }
// 0. Auth Verification // 0. Auth Verification
@@ -182,11 +202,6 @@ export async function adminVerifyToken(token: string): Promise<boolean> {
} }
} }
// 1. Stats Summary
export async function adminGetStatsSummary(): Promise<AdminStatsSummary> {
return request<AdminStatsSummary>("/stats/summary");
}
// 2. Research Projects // 2. Research Projects
export async function adminGetResearchProjects(): Promise<AdminResearchProject[]> { export async function adminGetResearchProjects(): Promise<AdminResearchProject[]> {
return request<AdminResearchProject[]>("/research-projects"); return request<AdminResearchProject[]>("/research-projects");
+22 -5
View File
@@ -10,17 +10,34 @@ import type {
DocStatus, DocStatus,
} from "../content/types"; } from "../content/types";
export function normalizeApiUrl(rawUrl?: string): string {
let url = (rawUrl || "").trim().replace(/\/+$/, "");
if (!url || url === "/" || url === "/api" || url === "/api/v1") {
return "/api/v1";
}
if (url.endsWith("/api/v1")) {
return url;
}
if (url.endsWith("/api")) {
return `${url}/v1`;
}
return `${url}/api/v1`;
}
export function getApiBaseUrl(): string { export function getApiBaseUrl(): string {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// Browser / CSR context: same-origin relative path by default. // Browser / CSR context:
return process.env.NEXT_PUBLIC_API_BASE_URL || "/api/v1"; if (process.env.NEXT_PUBLIC_API_BASE_URL) {
return normalizeApiUrl(process.env.NEXT_PUBLIC_API_BASE_URL);
}
return "/api/v1";
} }
// Server / build-time context: defensive fallback during static prerendering // Server / build-time context: defensive fallback during static prerendering
return ( const raw =
process.env.API_BASE_URL || process.env.API_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL ||
"http://localhost:8080/api/v1" "http://localhost:8080/api/v1";
); return normalizeApiUrl(raw);
} }
const FETCH_TIMEOUT_MS = 3000; const FETCH_TIMEOUT_MS = 3000;
+3
View File
@@ -3,6 +3,9 @@ const nextConfig = {
output: "export", output: "export",
trailingSlash: true, trailingSlash: true,
reactStrictMode: true, reactStrictMode: true,
images: {
unoptimized: true,
},
}; };
module.exports = nextConfig; module.exports = nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

@@ -150,7 +150,7 @@ def verify():
try: try:
def fetch_json(endpoint): def fetch_json(endpoint):
req = urllib.request.Request(f"{API_BASE_URL}{endpoint}", headers={"User-Agent": "DataVerifier"}) req = urllib.request.Request(f"{API_BASE_URL}{endpoint}", headers={"User-Agent": "DataVerifier"})
with urllib.request.urlopen(req, timeout=3) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
return data.get("data", []) return data.get("data", [])