Compare commits
14
Commits
9bdc9bdd9a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3972cac27 | ||
|
|
b0bce3be8e | ||
|
|
ca6e85ce9b | ||
|
|
3dc89970c6 | ||
|
|
cda9c6ea4f | ||
|
|
d1213e5c7f | ||
|
|
78a66b7ad4 | ||
|
|
1410dcf89d | ||
|
|
d3dc7f19d9 | ||
|
|
4c78ea819f | ||
|
|
5285e3fd02 | ||
|
|
575fbdb6de | ||
|
|
643ce8376c | ||
|
|
54fe425b3c |
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
← Return to Public Site
|
← 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 & 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 & 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 & 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
@@ -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 & Conference
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
</Reveal>
|
||||||
International Journals & 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 & 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 & 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>
|
||||||
);
|
);
|
||||||
|
|||||||
+71
-16
@@ -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"
|
||||||
← Overview
|
>
|
||||||
</Link>
|
← 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 →
|
||||||
|
</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 →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
|
||||||
“{doc.title}”
|
|
||||||
</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">
|
||||||
|
“{doc.title}”
|
||||||
|
</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 |
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,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", [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user