feat(frontend): connect Next.js server components to Go backend REST API with resilient fallback

- Implement typed REST API client in refer_landing_page/lib/api.ts
- Support API_BASE_URL environment variable with graceful fallback to static content
- Connect Home page (projects, areas, stats), Members page (active, alumni), Publications (highlights, list, patents), Lectures, and Standardization pages to backend endpoints
- Pass all 12 SSG build, TypeScript, ESLint, and E2E quality gates
This commit is contained in:
2026-08-24 18:40:46 +09:00
parent fb6881070c
commit 9cb6258d3d
10 changed files with 415 additions and 40 deletions
+6
View File
@@ -0,0 +1,6 @@
# ==============================================================================
# AI Agent Networking Lab (ANL) Next.js Frontend Configuration
# ==============================================================================
# Backend REST API Base URL (used at build time / SSR)
API_BASE_URL=http://localhost:8080/api/v1
@@ -1,9 +1,15 @@
import React from "react";
import { researchProjects } from "../../content/projects";
import { researchProjects as staticProjects } from "../../content/projects";
import type { ResearchProject } from "../../content/types";
import ProjectPageViewControls from "./ProjectPageViewControls";
export function ProjectPageView() {
const slugs = researchProjects.map((p) => p.slug);
interface ProjectPageViewProps {
projects?: ResearchProject[];
}
export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps = {}) {
const projects = projectsProp ?? staticProjects;
const slugs = projects.map((p) => p.slug);
const trackId = "research-projects-track";
return (
@@ -21,7 +27,7 @@ export function ProjectPageView() {
</h2>
</div>
<p className="text-xs text-ink-mute font-mono">
{researchProjects.length} core international standardization research projects
{projects.length} core international standardization research projects
</p>
</div>
@@ -34,13 +40,13 @@ export function ProjectPageView() {
aria-label="연구 프로젝트 목록"
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"
>
{researchProjects.map((proj, idx) => (
{projects.map((proj, idx) => (
<article
key={proj.slug}
id={`project-${proj.slug}`}
role="group"
aria-roledescription="slide"
aria-label={`${researchProjects.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]"
>
<div className="space-y-4">
+5 -2
View File
@@ -1,13 +1,16 @@
import React from "react";
import type { Metadata } from "next";
import { semesters } from "../../content/lectures";
import { semesters as staticSemesters } from "../../content/lectures";
import { getSemesters } from "../../lib/api";
export const metadata: Metadata = {
title: "Lectures",
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
};
export default function LecturesPage() {
export default async function LecturesPage() {
const semesters = (await getSemesters()) ?? staticSemesters;
const recentSemesters = semesters.slice(0, 3);
const olderSemesters = semesters.slice(3);
+6 -2
View File
@@ -1,6 +1,7 @@
import React from "react";
import type { Metadata } from "next";
import { activeMembers, alumni } from "../../content/members";
import { activeMembers as staticMembers, alumni as staticAlumni } from "../../content/members";
import { getMembers, getAlumni } from "../../lib/api";
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
export const metadata: Metadata = {
@@ -18,7 +19,10 @@ const DEGREE_LABEL: Record<string, string> = {
MS: "M. S.",
};
export default function MembersPage() {
export default async function MembersPage() {
const activeMembers = (await getMembers()) ?? staticMembers;
const alumni = (await getAlumni()) ?? staticAlumni;
const phdStudents = activeMembers.filter(
(m) =>
m.degree === "PhD" ||
+25 -18
View File
@@ -2,8 +2,10 @@ import React from "react";
import Link from "next/link";
import HeroComposition from "./_components/HeroComposition";
import { ProjectPageView } from "./_components/ProjectPageView";
import { publications, patents } from "../content/publications";
import { standardsBodies } from "../content/standards";
import { publications as staticPubs, patents as staticPatents } from "../content/publications";
import { standardsBodies as staticStandards } from "../content/standards";
import { researchProjects as staticProjects } from "../content/projects";
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
// CUSTOMIZATION HOOK: 14 Core Research Areas
const RESEARCH_AREAS: string[] = [
@@ -31,16 +33,21 @@ const INTERNATIONAL_SDOS = [
"ISO/IEC JTC1/SC6: Reliable Multicasting",
];
export default function HomePage() {
const intlPubsCount = publications.filter(
(p) => p.category === "intl-journal-conf"
).length;
const patentCount = patents.length;
const stdDocsCount = standardsBodies.reduce(
(sum, b) =>
sum + b.projects.reduce((pSum, p) => pSum + p.documents.length, 0),
0
);
export default async function HomePage() {
const stats = (await getStatsSummary()) ?? {
intlPubsCount: staticPubs.filter(
(p) => p.category === "intl-journal-conf"
).length,
patentCount: staticPatents.length,
stdDocsCount: staticStandards.reduce(
(sum, b) =>
sum + b.projects.reduce((pSum, p) => pSum + p.documents.length, 0),
0
),
};
const researchAreas = (await getResearchAreaNames()) ?? RESEARCH_AREAS;
const researchProjects = (await getResearchProjects()) ?? staticProjects;
return (
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
@@ -66,7 +73,7 @@ export default function HomePage() {
<div className="pt-6 border-t border-line grid grid-cols-3 gap-4">
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{intlPubsCount}
{stats.intlPubsCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
International Journals &amp; Conference
@@ -74,7 +81,7 @@ export default function HomePage() {
</div>
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{stdDocsCount}
{stats.stdDocsCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Standardization Contributions
@@ -82,7 +89,7 @@ export default function HomePage() {
</div>
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{patentCount}
{stats.patentCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Patent<br />
@@ -101,7 +108,7 @@ export default function HomePage() {
</section>
{/* Research Project PageView Component */}
<ProjectPageView />
<ProjectPageView projects={researchProjects} />
{/* 14 Research Areas Grid */}
<section className="space-y-8 pt-16 md:pt-20 border-t border-line">
@@ -118,12 +125,12 @@ export default function HomePage() {
</h2>
</div>
<p className="text-xs text-ink-mute font-mono">
{RESEARCH_AREAS.length} fundamental networking protocols and AI system domains
{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">
{RESEARCH_AREAS.map((area, idx) => {
{researchAreas.map((area, idx) => {
const no = String(idx + 1).padStart(2, "0");
const isOdd = (idx + 1) % 2 !== 0;
@@ -2,7 +2,8 @@ import React from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CategoryNav } from "../_components/CategoryNav";
import { publications, patents } from "../../../content/publications";
import { publications as staticPubs, patents as staticPatents } from "../../../content/publications";
import { getPublications, getPatents } from "../../../lib/api";
import { PUB_CATEGORIES } from "../../../content/categories";
import { PubCategory } from "../../../content/types";
@@ -30,7 +31,7 @@ export function generateStaticParams() {
}));
}
export default function PublicationCategoryPage({ params }: PageProps) {
export default async function PublicationCategoryPage({ params }: PageProps) {
const { category } = params;
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
@@ -40,6 +41,15 @@ export default function PublicationCategoryPage({ params }: PageProps) {
const slug = category as PubCategory;
const publications = (await getPublications()) ?? staticPubs;
const patents = (await getPatents()) ?? staticPatents;
const counts = {
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
patent: patents.length,
};
let records: any[] = [];
if (slug === "patent") {
records = patents.map((p) => ({
@@ -82,7 +92,7 @@ export default function PublicationCategoryPage({ params }: PageProps) {
{/* Category Nav Cards */}
<section className="pt-6 md:pt-8 border-t border-line">
<CategoryNav currentCategory={slug} />
<CategoryNav currentCategory={slug} counts={counts} />
</section>
{/* Record List Section */}
@@ -1,17 +1,27 @@
import React from "react";
import Link from "next/link";
import { PUB_CATEGORIES } from "../../../content/categories";
import { publications, patents } from "../../../content/publications";
import { publications as staticPubs, patents as staticPatents } from "../../../content/publications";
import { PubCategory } from "../../../content/types";
export interface CategoryCounts {
"intl-journal-conf"?: number;
"domestic-journal-conf"?: number;
patent?: number;
}
interface CategoryNavProps {
currentCategory?: PubCategory;
counts?: CategoryCounts;
}
export function CategoryNav({ currentCategory }: CategoryNavProps) {
export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
const getCount = (slug: string) => {
if (slug === "patent") return patents.length;
return publications.filter((p) => p.category === slug).length;
if (counts?.[slug as keyof CategoryCounts] !== undefined) {
return counts[slug as keyof CategoryCounts]!;
}
if (slug === "patent") return staticPatents.length;
return staticPubs.filter((p) => p.category === slug).length;
};
return (
+13 -3
View File
@@ -1,13 +1,23 @@
import type { Metadata } from "next";
import { CategoryNav } from "./_components/CategoryNav";
import { publications } from "../../content/publications";
import { publications as staticPubs, patents as staticPatents } from "../../content/publications";
import { getPublications, getPatents } from "../../lib/api";
export const metadata: Metadata = {
title: "Publications",
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
};
export default function PublicationsIndexPage() {
export default async function PublicationsIndexPage() {
const publications = (await getPublications()) ?? staticPubs;
const patents = (await getPatents()) ?? staticPatents;
const counts = {
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
patent: patents.length,
};
// Selected representative publications (curated via database isHighlight flag)
const highlights = publications.filter((p) => p.isHighlight);
@@ -22,7 +32,7 @@ export default function PublicationsIndexPage() {
{/* Category Summary Cards */}
<section className="pt-6 md:pt-8 border-t border-line">
<CategoryNav />
<CategoryNav counts={counts} />
</section>
{/* Highlights Section */}
@@ -1,13 +1,15 @@
import React from "react";
import type { Metadata } from "next";
import { standardsBodies } from "../../content/standards";
import { standardsBodies as staticStandards } from "../../content/standards";
import { getStandardsBodies } from "../../lib/api";
export const metadata: Metadata = {
title: "Standardization",
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
};
export default function StandardizationPage() {
export default async function StandardizationPage() {
const standardsBodies = (await getStandardsBodies()) ?? staticStandards;
return (
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
{/* Page Header */}
+317
View File
@@ -0,0 +1,317 @@
import type {
Member,
Alumnus,
Publication,
Patent,
Semester,
StandardsBody,
ResearchProject,
PubCategory,
DocStatus,
} from "../content/types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8080/api/v1";
const FETCH_TIMEOUT_MS = 3000;
interface APIResponse<T> {
data: T;
}
// Raw backend response schemas (snake_case)
interface BackendResearchProject {
id: number;
slug: string;
title: string;
abstract: string;
keywords: string[];
organization?: string | null;
standards_org?: string | null;
period?: string | null;
funder?: string | null;
}
interface BackendResearchArea {
id: number;
name_en: string;
name_kr?: string | null;
display_order: number;
}
interface BackendStatsSummary {
intl_publications: number;
standardization_docs: number;
patents: number;
}
interface BackendMember {
id: number;
name_ko: string;
name_en: string;
degree: string;
affiliation?: string | null;
email?: string | null;
is_advisor: boolean;
display_order: number;
}
interface BackendAlumnus {
id: number;
name_ko: string;
name_en: string;
degree: string;
graduated_at: string;
major?: string | null;
current_position?: string | null;
}
interface BackendPublication {
id: number;
category: string;
title: string;
authors?: string | null;
venue: string;
volume?: string | null;
published_at: string;
kci: boolean;
doi?: string | null;
is_highlight: boolean;
}
interface BackendPatent {
id: number;
title: string;
inventors: string;
application_no: string;
application_at: string;
registration_no?: string | null;
registration_at?: string | null;
country: string;
published_at: string;
}
interface BackendCourse {
id: number;
semester_id: number;
code: string;
name_en: string;
name_kr?: string | null;
note?: string | null;
display_order: number;
}
interface BackendSemester {
id: number;
year: number;
term: string;
courses: BackendCourse[];
}
interface BackendStandardDoc {
id: number;
body_id: number;
wg: string;
project_name: string;
title: string;
doc_ref: string;
status: string;
published_at?: string | null;
}
interface BackendStandardProject {
wg: string;
name: string;
documents: BackendStandardDoc[];
}
interface BackendStandardsBody {
id: number;
org: string;
full_name: string;
scope: string;
period: string;
role?: string | null;
display_order: number;
projects: BackendStandardProject[];
}
/**
* Generic fetch helper with bounded timeout and safe JSON handling.
* Returns data payload or null on any failure (network, timeout, non-2xx, parse error).
*/
async function apiGet<T>(path: string): Promise<T | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(`${API_BASE_URL}${path}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
return null;
}
const json = (await res.json()) as APIResponse<T>;
return json?.data ?? null;
} catch {
return null;
}
}
// --- Home Domain ---
export async function getResearchProjects(): Promise<ResearchProject[] | null> {
const raw = await apiGet<BackendResearchProject[]>("/research-projects");
if (!raw) return null;
return raw.map((p) => ({
slug: p.slug,
title: p.title,
abstract: p.abstract,
keywords: p.keywords,
standardsTrack: p.organization ?? undefined,
standardsOrg: p.standards_org ?? undefined,
period: p.period ?? undefined,
funder: p.funder ?? undefined,
deliverables: [],
}));
}
export async function getResearchAreaNames(): Promise<string[] | null> {
const raw = await apiGet<BackendResearchArea[]>("/research-areas");
if (!raw) return null;
return raw.map((a) => a.name_en);
}
export async function getStatsSummary(): Promise<{
intlPubsCount: number;
stdDocsCount: number;
patentCount: number;
} | null> {
const raw = await apiGet<BackendStatsSummary>("/stats/summary");
if (!raw) return null;
return {
intlPubsCount: raw.intl_publications,
stdDocsCount: raw.standardization_docs,
patentCount: raw.patents,
};
}
// --- Members Domain ---
export async function getMembers(): Promise<Member[] | null> {
const raw = await apiGet<BackendMember[]>("/members");
if (!raw) return null;
return raw.map((m) => ({
ko: m.name_ko,
en: m.name_en,
degree: m.degree as Member["degree"],
affiliation: m.affiliation ?? undefined,
}));
}
export async function getAlumni(): Promise<Alumnus[] | null> {
const raw = await apiGet<BackendAlumnus[]>("/alumni");
if (!raw) return null;
return raw.map((a) => ({
ko: a.name_ko,
en: a.name_en,
degree: a.degree as Alumnus["degree"],
graduatedAt: a.graduated_at,
major: a.major ?? undefined,
currentPosition: a.current_position ?? undefined,
}));
}
// --- Publications & Patents Domain ---
export async function getPublications(
category?: "intl-journal-conf" | "domestic-journal-conf"
): Promise<Publication[] | null> {
const raw = await apiGet<BackendPublication[]>(
category ? `/publications?category=${category}` : "/publications"
);
if (!raw) return null;
return raw.map((p) => ({
category: p.category as PubCategory,
title: p.title,
authors: p.authors ?? undefined,
venue: p.venue,
volume: p.volume ?? undefined,
publishedAt: p.published_at,
kci: p.kci,
doi: p.doi ?? undefined,
isHighlight: p.is_highlight,
}));
}
export async function getHighlights(): Promise<Publication[] | null> {
const raw = await apiGet<BackendPublication[]>("/publications/highlights");
if (!raw) return null;
return raw.map((p) => ({
category: p.category as PubCategory,
title: p.title,
authors: p.authors ?? undefined,
venue: p.venue,
volume: p.volume ?? undefined,
publishedAt: p.published_at,
kci: p.kci,
doi: p.doi ?? undefined,
isHighlight: p.is_highlight,
}));
}
export async function getPatents(): Promise<Patent[] | null> {
const raw = await apiGet<BackendPatent[]>("/patents");
if (!raw) return null;
return raw.map((p) => ({
category: "patent",
title: p.title,
inventors: p.inventors,
applicationNo: p.application_no,
applicationAt: p.application_at,
registrationNo: p.registration_no ?? undefined,
registrationAt: p.registration_at ?? undefined,
country: p.country,
publishedAt: p.published_at,
}));
}
// --- Lectures Domain ---
export async function getSemesters(): Promise<Semester[] | null> {
const raw = await apiGet<BackendSemester[]>("/semesters");
if (!raw) return null;
return raw.map((s) => ({
term: s.term as Semester["term"],
year: s.year,
courses: s.courses.map((c) => ({
code: c.code,
en: c.name_en,
note: c.note ?? undefined,
})),
}));
}
// --- Standardization Domain ---
export async function getStandardsBodies(): Promise<StandardsBody[] | null> {
const raw = await apiGet<BackendStandardsBody[]>("/standards-bodies");
if (!raw) return null;
return raw.map((b) => ({
org: b.org,
full: b.full_name,
scope: b.scope as StandardsBody["scope"],
period: b.period,
role: b.role ?? undefined,
projects: b.projects.map((p) => ({
wg: p.wg,
name: p.name,
documents: p.documents.map((d) => ({
title: d.title,
ref: d.doc_ref,
status: d.status as DocStatus,
publishedAt: d.published_at ?? undefined,
})),
})),
}));
}