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,6 +14,7 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
@@ -29,6 +31,7 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
|||||||
{projects.length} core international standardization research projects
|
{projects.length} core international standardization research projects
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* 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") {
|
|
||||||
router.push("/console/login");
|
|
||||||
} else {
|
|
||||||
setIsAuthenticated(false);
|
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 (
|
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 items-center justify-center p-4">
|
||||||
|
<AdminLoginForm onLoginSuccess={() => setIsAuthenticated(true)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
||||||
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span>
|
<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,23 +332,68 @@ 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">
|
||||||
|
<span className="text-xs font-semibold text-ink/70">Category:</span>
|
||||||
<select
|
<select
|
||||||
value={filterCategory}
|
value={filterCategory}
|
||||||
onChange={(e) => setFilterCategory(e.target.value)}
|
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"
|
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="all">
|
||||||
|
All Categories ({publications.filter((p) => filterYear === "all" || (p.published_at || "").includes(filterYear)).length})
|
||||||
|
</option>
|
||||||
<option value="intl-journal-conf">
|
<option value="intl-journal-conf">
|
||||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
|
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
|
||||||
</option>
|
</option>
|
||||||
<option value="domestic-journal-conf">
|
<option value="domestic-journal-conf">
|
||||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
|
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 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">
|
||||||
<table className="w-full text-left text-sm border-collapse">
|
<table className="w-full text-left text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -379,6 +460,45 @@ export default function AdminPublicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Patents Table */
|
/* Patents Table */
|
||||||
|
<div>
|
||||||
|
{/* Filter sub-bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-ink/70">Year:</span>
|
||||||
|
<select
|
||||||
|
value={filterPatentYear}
|
||||||
|
onChange={(e) => setFilterPatentYear(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 ({patents.length})</option>
|
||||||
|
{patentYears.map((yr) => {
|
||||||
|
const count = patents.filter((p) => {
|
||||||
|
const d = p.published_at || p.application_at || p.registration_at || "";
|
||||||
|
return d.includes(yr);
|
||||||
|
}).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">
|
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||||
<table className="w-full text-left text-sm border-collapse">
|
<table className="w-full text-left text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -390,14 +510,14 @@ export default function AdminPublicationsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-line">
|
||||||
{patents.length === 0 ? (
|
{filteredPatents.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||||
No patents found.
|
No patents found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
patents.map((p) => (
|
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,13 +24,16 @@ 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>
|
||||||
|
<Reveal variant="up">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
LECTURES
|
LECTURES
|
||||||
</h1>
|
</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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
Recent Courses
|
Recent Courses
|
||||||
@@ -38,12 +42,18 @@ export default function LecturesPage() {
|
|||||||
Latest 3 Semesters
|
Latest 3 Semesters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm h-full"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center border-b border-line pb-2.5">
|
<div className="flex justify-between items-center border-b border-line pb-2.5">
|
||||||
@@ -74,13 +84,16 @@ export default function LecturesPage() {
|
|||||||
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</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">
|
||||||
|
<div className="border border-line rounded-lg bg-paper p-5 sm:p-6 transition-all duration-200">
|
||||||
|
<details className="group">
|
||||||
<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">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||||
@@ -127,6 +140,8 @@ export default function LecturesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
</div>
|
||||||
|
</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,13 +44,16 @@ 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>
|
||||||
|
<Reveal variant="up">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
LAB MEMBERS
|
LAB MEMBERS
|
||||||
</h1>
|
</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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
<div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||||
<div className="lg:col-span-8 space-y-4">
|
<div className="lg:col-span-8 space-y-4">
|
||||||
<h2 className="font-serif text-3xl font-normal text-ink">
|
<h2 className="font-serif text-3xl font-normal text-ink">
|
||||||
@@ -80,19 +85,25 @@ export default function MembersPage() {
|
|||||||
</a>
|
</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">
|
<div className="lg:col-span-4 flex justify-center items-center">
|
||||||
<div className="text-ink font-bold border-b border-line pb-1">
|
<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">
|
||||||
Affiliation & Office
|
<Image
|
||||||
</div>
|
src="/images/prof_profile.png"
|
||||||
<p className="text-ink-mute">Kyungpook National University</p>
|
alt="Prof. Seok-Joo Koh"
|
||||||
<p className="text-ink-mute">IT Building #5 Room 527</p>
|
fill
|
||||||
<p className="text-ink-mute">Daegu, Republic of Korea</p>
|
sizes="(max-width: 1024px) 224px, 240px"
|
||||||
|
className="object-cover object-top"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
</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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
@@ -100,17 +111,25 @@ export default function MembersPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* Ph.D. Candidates / Students */}
|
{/* Ph.D. Candidates / Students */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<Reveal variant="up">
|
||||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||||
Ph.D. Course ({phdStudents.length})
|
Ph.D. Course ({phdStudents.length})
|
||||||
</h3>
|
</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="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<span className="font-serif text-lg font-normal text-ink">
|
<span className="font-serif text-lg font-normal text-ink">
|
||||||
@@ -127,20 +146,28 @@ export default function MembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
||||||
|
<Reveal variant="up">
|
||||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||||
M.S. Course ({msStudents.length})
|
M.S. Course ({msStudents.length})
|
||||||
</h3>
|
</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="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<span className="font-serif text-lg font-normal text-ink">
|
<span className="font-serif text-lg font-normal text-ink">
|
||||||
@@ -157,6 +184,7 @@ export default function MembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +192,7 @@ 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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||||
@@ -171,6 +200,7 @@ export default function MembersPage() {
|
|||||||
</h2>
|
</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">
|
||||||
|
|||||||
@@ -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,43 +42,52 @@ 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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||||
Kyungpook National University · AI Agent Networking Lab (ANL)
|
Kyungpook National University · AI Agent Networking Lab (ANL)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
||||||
|
<Reveal variant="up">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
||||||
Multi-Agent <br />
|
Multi-Agent <br />
|
||||||
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl">
|
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl mt-4">
|
||||||
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.
|
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>
|
</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">
|
||||||
|
<Reveal variant="up" delay={80}>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||||
{stats.intlPubsCount}
|
<Counter value={stats.intlPubsCount} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||||
International Journals & Conference
|
International Journals & Conference
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal variant="up" delay={160}>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||||
{stats.stdDocsCount}
|
<Counter value={stats.stdDocsCount} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||||
Standardization Contributions
|
Standardization Contributions
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal variant="up" delay={240}>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||||
{stats.patentCount}
|
<Counter value={stats.patentCount} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||||
Patent<br />
|
Patent<br />
|
||||||
@@ -85,12 +96,13 @@ export default function HomePage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</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,6 +111,7 @@ 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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
@@ -115,6 +128,7 @@ export default function HomePage() {
|
|||||||
{researchAreas.length} fundamental networking protocols and AI system domains
|
{researchAreas.length} fundamental networking protocols and AI system domains
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<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,8 +136,13 @@ export default function HomePage() {
|
|||||||
const isOdd = (idx + 1) % 2 !== 0;
|
const isOdd = (idx + 1) % 2 !== 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Reveal
|
||||||
key={no}
|
key={no}
|
||||||
|
variant="up"
|
||||||
|
delay={Math.min(idx, 6) * 60}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
|
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -144,6 +163,7 @@ export default function HomePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,6 +171,7 @@ 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">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
@@ -164,7 +185,9 @@ export default function HomePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6">
|
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6">
|
||||||
<p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl">
|
<p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl">
|
||||||
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):
|
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):
|
||||||
@@ -188,6 +211,7 @@ export default function HomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+61
-6
@@ -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">
|
||||||
|
<Reveal variant="up">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
{currentCat.label}
|
{currentCat.label}
|
||||||
</h1>
|
</h1>
|
||||||
{currentCat.subtitle && (
|
{currentCat.subtitle && (
|
||||||
<p className="font-mono text-sm text-ink-mute">
|
<p className="font-mono text-sm text-ink-mute mt-1">
|
||||||
{currentCat.subtitle}
|
{currentCat.subtitle}
|
||||||
</p>
|
</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">
|
||||||
|
<div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
|
||||||
|
<select
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={(e) => setSelectedYear(e.target.value)}
|
||||||
|
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
|
||||||
|
>
|
||||||
|
<option value="all">All Years ({allRecords.length})</option>
|
||||||
|
{availableYears.map((yr) => {
|
||||||
|
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
|
||||||
|
return (
|
||||||
|
<option key={yr} value={yr}>
|
||||||
|
{yr} ({yrCount})
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
{selectedYear !== "all" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedYear("all")}
|
||||||
|
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||||
{records.length} Records
|
Showing {records.length} of {allRecords.length} Records
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</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,6 +26,7 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<Reveal variant="up">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
Categories
|
Categories
|
||||||
@@ -37,16 +40,22 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
|||||||
</Link>
|
</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"
|
||||||
|
delay={idx * 80}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all h-full ${
|
||||||
isActive
|
isActive
|
||||||
? "border-2 border-cobalt-dark shadow-md"
|
? "border-2 border-cobalt-dark shadow-md"
|
||||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||||
@@ -84,7 +93,7 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
|||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
<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">
|
<span className="font-display text-3xl font-bold text-ink">
|
||||||
{count}
|
<Counter value={count} />
|
||||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||||
</span>
|
</span>
|
||||||
@@ -105,6 +114,7 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
|
<Reveal variant="up">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
PUBLICATIONS
|
PUBLICATIONS
|
||||||
</h1>
|
</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">
|
||||||
|
<Reveal variant="up">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
Highlights
|
Highlights
|
||||||
</h2>
|
</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,15 +17,22 @@ 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>
|
||||||
|
<Reveal variant="up">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
International Standardization
|
International Standardization
|
||||||
</h1>
|
</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
|
||||||
|
key={idx}
|
||||||
|
variant="up"
|
||||||
|
delay={idx * 120}
|
||||||
|
>
|
||||||
|
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6 shadow-sm">
|
||||||
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
|
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -105,6 +113,7 @@ export default function StandardizationPage() {
|
|||||||
))}
|
))}
|
||||||
</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,9 +164,15 @@ async function request<T>(
|
|||||||
|
|
||||||
const baseUrl = getAdminApiBaseUrl();
|
const baseUrl = getAdminApiBaseUrl();
|
||||||
const url = `${baseUrl}${endpoint}`;
|
const url = `${baseUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,6 +187,9 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json.data;
|
return json.data;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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