Compare commits
16
Commits
0a589bd3a2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3972cac27 | ||
|
|
b0bce3be8e | ||
|
|
ca6e85ce9b | ||
|
|
3dc89970c6 | ||
|
|
cda9c6ea4f | ||
|
|
d1213e5c7f | ||
|
|
78a66b7ad4 | ||
|
|
1410dcf89d | ||
|
|
d3dc7f19d9 | ||
|
|
4c78ea819f | ||
|
|
5285e3fd02 | ||
|
|
575fbdb6de | ||
|
|
643ce8376c | ||
|
|
54fe425b3c | ||
|
|
9bdc9bdd9a | ||
|
|
5f87631530 |
+8
-7
@@ -1,15 +1,16 @@
|
||||
# ==============================================================================
|
||||
# AI Agent Networking Lab (ANL) Unified Docker Compose Environment Configuration
|
||||
# AI Agent Networking Lab (ANL) Unified Single Backend Environment Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# REQUIRED: Secret Bearer token for mutating REST APIs and /console admin operations
|
||||
# Generate a secure random token (e.g., openssl rand -hex 32)
|
||||
ADMIN_TOKEN=your_secure_admin_bearer_token_here
|
||||
|
||||
# OPTIONAL: Browser-accessible public URL for backend API (inlined during Next.js build)
|
||||
# Default: http://localhost:8080/api/v1
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
||||
# OPTIONAL: Public API base URL for client-side fetches.
|
||||
# In standard deployment (Go backend serving both static site and API on the same origin),
|
||||
# leave this UNSET or empty to use origin-relative '/api/v1' automatically.
|
||||
# Only specify an absolute URL (e.g., https://api.domain.com/api/v1) for cross-origin setups.
|
||||
NEXT_PUBLIC_API_BASE_URL=
|
||||
|
||||
# OPTIONAL: Host port mappings
|
||||
BACKEND_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
# OPTIONAL: Host port mapping for unified container (default: 8080)
|
||||
APP_PORT=8080
|
||||
|
||||
@@ -25,3 +25,5 @@
|
||||
/.mam-skill-backup.*/
|
||||
CURRENT_JOB.md
|
||||
# <<< MAM managed block <<<
|
||||
.env
|
||||
docker-compose.yaml
|
||||
@@ -3,7 +3,7 @@
|
||||
> **AI Agent Networking Laboratory (ANL), School of Computer Science and Engineering, Kyungpook National University**
|
||||
>
|
||||
> 본 리포지토리는 경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실의 공식 웹사이트 및 데이터 관리 대시보드 시스템입니다.
|
||||
> 실시간 런타임 데이터 동기화를 지원하는 **Next.js 14+ 프론트엔드**, **Go (Gin) 고성능 RESTful 백엔드**, **SQLite 트랜잭션 데이터베이스**, 그리고 **통합 Docker 컨테이너 오케스트레이션**으로 구축되었습니다.
|
||||
> **Next.js 14+ 정적 익스포트(Static Export) & 클라이언트 렌더링(CSR)**, **Go (Gin) 고성능 단일 통합 웹/REST API 서버**, **SQLite 트랜잭션 데이터베이스**, 그리고 **단일 컨테이너 Docker 배포 환경**으로 구축되었습니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,31 +48,34 @@ graph TD
|
||||
User["🌐 User / Browser"]
|
||||
Admin["👑 Admin User"]
|
||||
|
||||
subgraph Docker_Network ["Docker Bridge Network (anl-net)"]
|
||||
subgraph Frontend_App ["Next.js 14 Frontend (anl-frontend:3000)"]
|
||||
PublicPages["Public Pages (force-dynamic)<br/>/, /members, /publications, /lectures, /standardization"]
|
||||
AdminConsole["Admin Dashboard (/console)<br/>Research Projects, Areas, Members, Pubs, Lectures, Standards"]
|
||||
API_Client["Typed API Client (lib/api.ts, lib/adminApi.ts)"]
|
||||
end
|
||||
|
||||
subgraph Backend_App ["Go Gin Backend API (anl-backend:8080)"]
|
||||
Router["REST API Router & CORS"]
|
||||
subgraph Docker_App ["Unified Single Container (anl-app:8080)"]
|
||||
subgraph Go_Gin_Server ["Go Gin Unified Server Engine"]
|
||||
Router["Static File Server & SPA Fallback (/web)"]
|
||||
APIRouter["REST API Router (/api/v1) & CORS"]
|
||||
AuthMW["RequireAdminToken Middleware"]
|
||||
Handlers["CRUD Handlers (Home, Members, Pubs, Lectures, Standards)"]
|
||||
Repo["Repository Layer & Transaction Order Normalizer"]
|
||||
end
|
||||
|
||||
subgraph Static_Web ["Static Exported Frontend (/app/web)"]
|
||||
PublicPages["Public Pages (CSR + SSG Shell)<br/>/, /members, /publications, /lectures, /standardization"]
|
||||
AdminConsole["Admin Dashboard (/console)<br/>Research Projects, Areas, Members, Pubs, Lectures, Standards"]
|
||||
API_Client["Origin-Relative API Client (/api/v1)"]
|
||||
end
|
||||
|
||||
subgraph Storage ["Persistent Storage (anl-sqlite-storage / ./volumes/data)"]
|
||||
SQLiteDB[("SQLite Database<br/>/app/data/anl.db")]
|
||||
end
|
||||
end
|
||||
|
||||
User -->|HTTP :3000| PublicPages
|
||||
Admin -->|Bearer Auth :3000| AdminConsole
|
||||
User -->|HTTP :8080| Router
|
||||
Admin -->|Bearer Auth :8080| Router
|
||||
Router --> PublicPages
|
||||
Router --> AdminConsole
|
||||
PublicPages --> API_Client
|
||||
AdminConsole --> API_Client
|
||||
API_Client -->|REST API :8080| Router
|
||||
Router --> AuthMW
|
||||
API_Client -->|Internal Route /api/v1| APIRouter
|
||||
APIRouter --> AuthMW
|
||||
AuthMW --> Handlers
|
||||
Handlers --> Repo
|
||||
Repo --> SQLiteDB
|
||||
@@ -85,26 +88,25 @@ graph TD
|
||||
```
|
||||
landing_page/
|
||||
├── README.md # [본 문서] 종합 프로젝트 가이드 및 배포/운영 문서
|
||||
├── docker-compose.yml # 통합 Docker Compose 멀티 서비스 오케스트레이션
|
||||
├── docker-compose.yml # 통합 단일 컨테이너 (anl-app) Compose 오케스트레이션
|
||||
├── .env.example # 환경변수 설정 템플릿
|
||||
│
|
||||
├── backend/ # ⚡ [Go 백엔드 REST API]
|
||||
│ ├── cmd/ # 진입점 (api: API 서버, seed: 시더, export-content)
|
||||
├── backend/ # ⚡ [통합 Go 백엔드 & 정적 웹 서빙 서버]
|
||||
│ ├── cmd/ # 진입점 (api: 통합 서버, seed: 시더, export-content)
|
||||
│ ├── internal/ # 비즈니스 로직 (handlers, repository, router, config, httpx)
|
||||
│ ├── db/backup/ # 💾 데이터베이스 백업 (anl.db 바이너리, anl_dump.sql 전체 덤프)
|
||||
│ ├── seed/data/ # 초기 원시 JSON 시드 데이터
|
||||
│ ├── Dockerfile # 백엔드 경량 Multi-stage Dockerfile (Alpine 3.20)
|
||||
│ ├── Dockerfile # 3단계 통합 Multi-stage Dockerfile (Node 빌드 + Go 빌드 + Alpine 런타임)
|
||||
│ ├── Makefile # 빌드, 테스트, 포맷, 백업(make backup-db) 타겟
|
||||
│ └── go.mod # Go 모듈 의존성 정의
|
||||
│
|
||||
├── refer_landing_page/ # 🚀 [Next.js 14 프론트엔드 & 관리자 콘솔]
|
||||
├── refer_landing_page/ # 🚀 [Next.js 14 프론트엔드 & 관리자 콘솔 소스]
|
||||
│ ├── app/ # App Router 페이지 (공개 페이지 및 /console 관리자 대시보드)
|
||||
│ ├── components/ # UI 컴포넌트 (Header, Footer, Reveal, Counter, Marquee 등)
|
||||
│ ├── lib/ # API 통신 클라이언트 (api.ts: 조회, adminApi.ts: CRUD)
|
||||
│ ├── docs/ # 디자인 시스템 (DESIGN.md: Issue 01 에디토리얼 스타일)
|
||||
│ ├── scripts/ # E2E 테스트 스위트 및 데이터 검증 러너
|
||||
│ ├── Dockerfile # 프론트엔드 Standalone Multi-stage Dockerfile (Node 20 Alpine)
|
||||
│ ├── next.config.js # output: "standalone" 프로덕션 번들링 설정
|
||||
│ ├── next.config.js # output: "export" 정적 익스포트 설정
|
||||
│ └── package.json # Next.js, React, Tailwind CSS 의존성
|
||||
│
|
||||
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 스킬 & 룰셋 (MAM)
|
||||
@@ -116,7 +118,7 @@ landing_page/
|
||||
|
||||
## 🚀 5. Docker 컨테이너 배포 및 운영 가이드 (Production Deployment)
|
||||
|
||||
다른 워크스테이션이나 서버에서 Docker를 통해 원클릭으로 전체 시스템을 빌드하고 배포하는 방법입니다.
|
||||
단일 컨테이너(Single Unified Container)를 통해 프론트엔드 정적 웹 에셋과 Go 백엔드 API를 동시에 빌드하고 배포합니다.
|
||||
|
||||
### 1) 저장소 클론 및 환경 설정
|
||||
```bash
|
||||
@@ -127,12 +129,12 @@ cd landing_page
|
||||
# 2. 환경변수 파일 생성
|
||||
cp .env.example .env
|
||||
|
||||
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트 변경 가능
|
||||
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트(APP_PORT) 설정
|
||||
```
|
||||
|
||||
### 2) 컨테이너 이미지 빌드
|
||||
```bash
|
||||
# 프론트엔드 및 백엔드 이미지 동시 빌드
|
||||
# 통합 단일 컨테이너 (anl-app) 빌드
|
||||
docker compose build
|
||||
|
||||
# (선택) 캐시 없이 전체 재빌드 시
|
||||
@@ -144,7 +146,7 @@ docker compose build --no-cache
|
||||
# 1. 컨테이너 백그라운드 실행
|
||||
docker compose up -d
|
||||
|
||||
# 2. 실행 상태 및 헬스체크 확인 (anl-backend-api 및 anl-frontend-app)
|
||||
# 2. 실행 상태 및 헬스체크 확인 (anl-app)
|
||||
docker compose ps
|
||||
|
||||
# 3. 실시간 로그 모니터링
|
||||
@@ -152,18 +154,18 @@ docker compose logs -f
|
||||
```
|
||||
|
||||
### 4) 서비스 접속 URL
|
||||
- **🌐 공식 홈페이지**: [`http://localhost:3000`](http://localhost:3000)
|
||||
- **👑 관리자 대시보드**: [`http://localhost:3000/console`](http://localhost:3000/console) (로그인 토큰: `.env`에 설정된 `ADMIN_TOKEN`)
|
||||
- **🌐 공식 홈페이지**: [`http://localhost:8080`](http://localhost:8080)
|
||||
- **👑 관리자 대시보드**: [`http://localhost:8080/console`](http://localhost:8080/console) (로그인 토큰: `.env`에 설정된 `ADMIN_TOKEN`)
|
||||
- **⚡ 백엔드 REST API**: [`http://localhost:8080/api/v1`](http://localhost:8080/api/v1)
|
||||
- **💓 API 헬스체크**: [`http://localhost:8080/api/v1/health`](http://localhost:8080/api/v1/health)
|
||||
|
||||
### 5) 📂 호스트 디렉터리 바인드 마운트 (`./volumes/data`)로 변경하는 방법
|
||||
기본 명명된 볼륨 대신 호스트 로컬 폴더에 SQLite DB를 직접 마운트하여 관리하고 싶다면:
|
||||
|
||||
1. `docker-compose.yml`의 `anl-backend` 볼륨 설정을 수정합니다:
|
||||
1. `docker-compose.yml`의 `anl-app` 볼륨 설정을 수정합니다:
|
||||
```yaml
|
||||
services:
|
||||
anl-backend:
|
||||
anl-app:
|
||||
# ...
|
||||
volumes:
|
||||
- ./volumes/data:/app/data
|
||||
@@ -192,140 +194,38 @@ docker compose down -v
|
||||
|
||||
## 🛠️ 6. 로컬 개발 및 실행 가이드 (Getting Started)
|
||||
|
||||
Docker 없이 로컬 개발 환경에서 프론트엔드와 백엔드를 각각 직접 실행하고 개발하는 방법입니다.
|
||||
|
||||
### 🐧 사전 필수 도구 설치 (Ubuntu / Linux)
|
||||
Ubuntu 환경에서 `sqlite3`, `curl`, `make`가 설치되어 있지 않다면 먼저 설치합니다:
|
||||
### 🅰️ Backend (Go REST API & Web Server) 개발 가이드
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y sqlite3 curl make
|
||||
```
|
||||
*(macOS의 경우: `brew install sqlite make`)*
|
||||
|
||||
---
|
||||
|
||||
### 🅰️ Backend (Go REST API) 개발 가이드
|
||||
|
||||
#### 사전 요구사항
|
||||
- **Go 1.22+** (권장: Go 1.26+)
|
||||
- **SQLite 3**
|
||||
- **Make** 빌드 도구
|
||||
|
||||
#### 실행 절차
|
||||
```bash
|
||||
# 1. 백엔드 디렉터리로 이동
|
||||
cd backend
|
||||
|
||||
# 2. 환경 설정 파일 복사
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Go 모듈 의존성 다운로드
|
||||
go mod download
|
||||
# 데이터베이스 시드 및 마이그레이션 실행
|
||||
make migrate-seed
|
||||
|
||||
# 4. 데이터베이스 백업본 복원 (최신 실측 데이터 223건 적재)
|
||||
sqlite3 anl.db < db/backup/anl_dump.sql
|
||||
|
||||
# 5. 백엔드 API 서버 빌드 및 실행 (기본 포트: http://localhost:8080)
|
||||
make run
|
||||
|
||||
# 6. (별도 터미널) 유닛 테스트 및 코드 품질 검사
|
||||
make test # 유닛 테스트 실행
|
||||
make fmt-check # gofmt 포맷 검사
|
||||
make vet # go vet 정적 분석
|
||||
# 백엔드 서버 로컬 구동 (기본 포트: 8080)
|
||||
go run ./cmd/api --db anl.db --admin-token dev_admin_secret_token_12345
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🅱️ Frontend (Next.js 14+) 개발 가이드
|
||||
|
||||
#### 사전 요구사항
|
||||
- **Node.js 20+** (LTS)
|
||||
- **npm 10+**
|
||||
|
||||
#### 실행 절차
|
||||
### 🅱️ Frontend (Next.js 14) 개발 가이드
|
||||
```bash
|
||||
# 1. 프론트엔드 디렉터리로 이동
|
||||
cd refer_landing_page
|
||||
|
||||
# 2. 의존성 패키지 설치
|
||||
# 종속성 설치
|
||||
npm install
|
||||
|
||||
# 3. 로컬 환경변수 파일 설정 (필요 시)
|
||||
# 기본값으로 http://localhost:8080/api/v1에 자동 연결됩니다.
|
||||
echo "NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1" > .env.local
|
||||
|
||||
# 4. 개발 서버 실행 (기본 포트: http://localhost:3000)
|
||||
# 개발 서버 실행 (Next.js dev 서버: http://localhost:3000)
|
||||
npm run dev
|
||||
|
||||
# 5. 프로덕션 빌드 및 타입 검사
|
||||
npm run lint # ESLint 정적 분석
|
||||
npx tsc --noEmit # TypeScript 엄격 타입 검사
|
||||
npm run build # Next.js Standalone 프로덕션 빌드
|
||||
# 정적 익스포트 빌드 검증 (out/ 디렉터리 생성)
|
||||
npm run build
|
||||
```
|
||||
|
||||
# 6. 전체 통합 E2E 테스트 스위트 실행 (Gate 0~11 자동 검증)
|
||||
---
|
||||
|
||||
## 🧪 7. 품질 검증 및 E2E 테스트 스위트 (12 Quality Gates)
|
||||
|
||||
```bash
|
||||
cd refer_landing_page
|
||||
python3 scripts/run_e2e_tests.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 7. 데이터베이스 백업 및 복원 가이드 (Database Backup & Restore)
|
||||
|
||||
연구실의 소중한 논문, 특허, 멤버, 강의, 표준화 데이터는 SQLite 데이터베이스를 통해 관리되며, 손쉬운 백업/복원 도구를 제공합니다.
|
||||
|
||||
### 1) 백업 파일 구성 (`backend/db/backup/`)
|
||||
- **`anl.db`**: SQLite 바이너리 온라인 스냅샷 파일
|
||||
- **`anl_dump.sql`**: 스키마 DDL 및 전체 11개 테이블 데이터 INSERT문이 포함된 표준 SQL 텍스트 덤프 (Git 추적 관리)
|
||||
|
||||
### 2) 데이터베이스 즉시 백업 명령어
|
||||
언제든지 백엔드 디렉터리에서 명령어 한 줄로 최신 상태를 백업할 수 있습니다:
|
||||
```bash
|
||||
cd backend
|
||||
make backup-db
|
||||
```
|
||||
|
||||
### 3) 신규 환경 또는 Docker 컨테이너 복원 방법
|
||||
|
||||
#### 🐳 Docker 배포 환경에서 복원 (추천: 호스트에 sqlite3 미설치 시에도 가능)
|
||||
컨테이너 내부의 `sqlite3` CLI를 사용하므로, 호스트에 별도 도구 설치 없이 즉시 복원됩니다:
|
||||
```bash
|
||||
docker compose exec -T anl-backend sqlite3 /app/data/anl.db < backend/db/backup/anl_dump.sql
|
||||
```
|
||||
|
||||
#### 💻 로컬 개발 환경에서 복원
|
||||
```bash
|
||||
cd backend
|
||||
sqlite3 anl.db < db/backup/anl_dump.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👑 8. 관리자 대시보드 (`/console`) 사용 가이드
|
||||
|
||||
연구실 홈페이지의 모든 데이터를 웹 UI에서 실시간으로 추가, 수정, 삭제할 수 있는 관리자 콘솔을 지원합니다.
|
||||
|
||||
1. **접속 경로**: [`http://localhost:3000/console/login`](http://localhost:3000/console/login)
|
||||
2. **관리자 인증**:
|
||||
- 백엔드 `.env` 파일에 정의된 `ADMIN_TOKEN` (기본값: `anl-admin-secret-token-2026`)을 입력하여 로그인합니다.
|
||||
- 인증 토큰은 브라우저 세션에 저장되며, 모든 변조 요청(`POST`, `PUT`, `DELETE`)에 `Authorization: Bearer <TOKEN>` 헤더로 자동 첨부됩니다.
|
||||
3. **제공 기능**:
|
||||
- **주요 연구 과제 (`/console/research-projects`)**: 과제명, 기간, 주관기관, 연구비 등 CRUD
|
||||
- **핵심 연구 분야 (`/console/research-areas`)**: 연구 분야 CRUD 및 **순서 변경 시 1..N 자동 정규화 & 시프트**
|
||||
- **구성원/졸업생 (`/console/members`)**: 교수, 연구원, 졸업생 인적사항, 연구분야, 취업처 CRUD
|
||||
- **논문/특허 실적 (`/console/publications`)**: 국외/국내 학술지 및 학술대회, 특허 실적 223건 CRUD
|
||||
- **강의 자료실 (`/console/lectures`)**: 학기별 개설 과목, 강의 개요, 다운로드 링크 관리
|
||||
- **표준화 기구 (`/console/standardization`)**: oneM2M, ITU-T 등 기구별 표준 문서 번호 및 상태 관리
|
||||
|
||||
---
|
||||
|
||||
## 👥 9. 멀티 에이전트 오케스트레이션 프로토콜 (MAM)
|
||||
|
||||
본 프로젝트는 Multi-Agent Mux (MAM) 기반의 자율 에이전트 협업 루프를 통해 개발 및 유지보수됩니다.
|
||||
|
||||
| 에이전트 세션명 | 담당 역할 (Role) | 도구/런타임 | 주요 임무 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 🧠 **`planner-reviewer-claude-01`** | `planner and reviewer` | Claude Code | 아키텍처 설계, 구현 계획 수립, 데이터 무결성 검증, 최종 PASS 판정 |
|
||||
| 🎨 **`creator-agy-01`** | `creator` | Antigravity CLI | 컴포넌트 개발, 백엔드 API 구현, Docker 컨테이너화, 리팩토링 |
|
||||
| 🔍 **`reviewer-cline-01`** | `reviewer` | Cline | 웹 표준/SEO 검수, 타입 안전성, Docker 네트워크/보안 심층 코드 리뷰 |
|
||||
|
||||
- **자율 루프 실행**: `bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh --all-reviewer --target-agent "creator-agy-01" --task "<작업내용>"`
|
||||
- **세션 상태 확인**: `bash .agents/skills/multi-agent-mux-status/scripts/status.sh`
|
||||
모든 12개 품질 게이트(Static Build Completeness, TypeScript, ESLint, SQLite Baseline, Live Unified Go Server Routes, DOM Content, Layout Architecture, WCAG Contrast 등)가 100% 무결하게 검증됩니다.
|
||||
|
||||
+4
-2
@@ -287,7 +287,7 @@ Home의 슬로건 히어로를 **실제 연구 프로젝트 카드 뷰**로 전
|
||||
| :--- | :--- | :-- |
|
||||
| DM-01 | 콘텐츠 데이터는 페이지 컴포넌트에서 분리해 **`content/` 디렉터리의 타입 지정 모듈**로 이관한다 (`content/members.ts`, `content/publications.ts`, `content/lectures.ts`, `content/standards.ts`) | MUST |
|
||||
| DM-02 | 각 모듈은 명시적 TypeScript 인터페이스를 export 한다. `any` 사용 금지 | MUST |
|
||||
| DM-03 *(v2.0 개정)* | 데이터는 **실시간 동적 런타임 페칭(`force-dynamic`, `cache: 'no-store'`)**으로 백엔드 API(`http://localhost:8080/api/v1`)로부터 조회한다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다. 6개 데이터 라우트는 `ƒ Dynamic`으로 렌더링되며, 4개 프레임워크 라우트는 `○ Static`을 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST |
|
||||
| DM-03 *(v3.0 개정)* | 데이터는 **Next.js 정적 Export (`output: 'export'`)** 환경에서 클라이언트 사이드 런타임 페칭(`useEffect`, `cache: 'no-store'`)으로 동일 오리진 백엔드 API(`/api/v1`)로부터 조회한다. 모든 공개 라우트 및 콘솔 라우트는 정적 번들(`○ Static` / `● SSG`)로 생성되어 Go 백엔드 서버에서 단일 서빙되며, `ƒ Dynamic` 라우트는 0건이다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST |
|
||||
| DM-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST |
|
||||
|
||||
### 7.2 스키마 정의 (계약)
|
||||
@@ -651,7 +651,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
||||
**품질 게이트**
|
||||
- [ ] AC-15 `npm run lint` 통과 (경고 0)
|
||||
- [ ] AC-16 `npx tsc --noEmit` 통과
|
||||
- [ ] AC-17 *(v2.0 개정)* `npm run build` 성공. 빌드 리포트에 **정확히 6개 데이터 라우트가 `ƒ (Dynamic)`**(`force-dynamic`)이고 **4개 프레임워크/메타데이터 라우트가 `○ (Static)`**으로 분리되어 생성됨을 확인한다 (§7.1 DM-03)
|
||||
- [ ] AC-17 *(v3.0 개정)* `npm run build` 성공. Next.js 정적 Export(`output: 'export'`)에 따라 빌드 산출물(`out/`)에 모든 공개 라우트 및 콘솔 라우트가 정적 파일(`○ Static` / `● SSG`)로 생성되며, `ƒ (Dynamic)` 라우트가 0건임을 확인한다 (§7.1 DM-03)
|
||||
- [ ] AC-18 First Load JS 증가분 ≤ 30 kB
|
||||
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
|
||||
- [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐)
|
||||
@@ -1053,3 +1053,5 @@ G-1은 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유
|
||||
| 1.13 | 2026-07-31 | claude (planner) | 테마 색상 통일성 검증 계획 수립 (`eaddc4f`→`082d71e` 4개 커밋). **Gate 0~10 전량 통과·빌드 exit 0인 상태에서 WCAG AA 미달 6건·통일성 결함 9건이 실재**함을 실측으로 확정 — 게이트 통과가 곧 오탐 신호였다. 원인은 게이트 **범위 결함 3종**: ⓐ **AC-44의 vermillion 절반이 구조적으로 무효**(면제 조항 `"text-vermillion" in line`이 판정 정규식과 항상 동시 매치 — 실소스 4라인 + 주입 2건으로 재현), ⓑ AC-45가 `-dark` 2종만 검사해 **`--cobalt` 반전 대비 1.00**(D-10 근본 원인의 재발)을 미검출, ⓒ AC-43이 소스의 전경/배경 쌍을 보지 않아 `bg-cobalt-dark text-white`(다크 2.85)를 통과. **R-8.30 신설**(비텍스트 UI 3:1 · 알파는 합성 후 산정 — `focus:ring-cobalt/50` **1.80** · `hover:border-cobalt/60` **1.95** 검출), **R-8.31 신설**(역할 시그니처 단위 색 통일 — 괘선 6:1 · 키커 11:1 · 배지 전경 6:4 · hover 테두리 4종 · 포커스 2종, 지도교수 이메일 1값이 3색 3대비), **R-8.32 신설**(게이트 면제는 `(파일,행,사유)` 등재만 허용 · 라인 부분문자열 면제 금지 — **N6과 동일 유형**). **R-8.5·R-8.26·R-8.28 개정**, **AC-42·43·44·45·47 개정**, **AC-48~AC-50 신설**(Gate 11 C11-1~8 · 폴트 인젝션 F1~F9 · `docs/DESIGN.md` 동기화 4건). **§8.1 색상표를 실제 토큰값 `#4E77FF`/`#345AD2`로 동기화**하고 **D-10 확정값을 #345AD2로 개정**(paper 4.76, 여유 0.26 → R-12). **D-12 신설**(🔴 Primary 적용의 판정 기준 — 라이트 테마에서 #4E77FF가 정적으로 보이는 곳이 **2곳뿐**이며 인지색은 #345AD2. 히어로 `Orchestration`이 R-8.5·DESIGN.md §2를 위반해 cobalt로 변경되어 한 페이지 안에서 규약이 분열), **D-13 신설**(🟡 `--vermillion` 반전 1.25). **A-1 `lectures:89` 4.16 미교정 잔존**·**A-2 `not-found.tsx:22` 4.16 신규 발견**(어떤 AC에도 없던 항목)·**A-7 `::selection` 4.16 미등재** 확정. **D-11 실측 갱신**(23건, 최저 3.77). 색 교체 5회 연속 대비표 미첨부(R-10). Job `43f3f74a` |
|
||||
| 1.14 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `4962a667`) 반영. **구조적 통찰 채택** — 다크 `--cobalt`만 단독 상향하면 다크 지면 대비가 DEFAULT 9.2 > `-dark` 5.96으로 **계층이 역전**됨을 실측 확인, v1.13 §7 각주의 누락을 인정하고 **R-8.33 신설**(토큰 등급 불변식). 다만 불변식을 **절대 휘도 순서가 아니라 테마 상대 대비**로 정식화 — 절대 순서의 테마 간 뒤집힘은 vermillion 쌍에서도 동일한 **정상 동작**이며 현행 8개 셀 전부에서 성립하므로, 절대 휘도로 판정하면 정상 상태를 오판한다. **제안 수치 미채택** — 주 권장값 `#85ACFF`의 반전 대비는 **1.73**으로 조정을 촉발한 R-8.28(≥2.0) 자체를 충족하지 못하며, 제안 표의 `#345AD2` L=0.095는 **폐기된 #32549E의 값**(실제 0.1270)이라 `#B0CDFF` 반전 대비 2.50도 실제 **3.68**이다. **이의제기 지점에서 더 큰 결함 발굴** — 다크 `--cobalt`(#4D77FF)는 `bg-paper` 위 **4.37**이므로 v1.12가 지시한 `text-cobalt-dark dark:text-cobalt` 라우팅이 **다크 절반을 DEFAULT로 되돌려 8곳이 AA 미달**이다(**AC-51 신설**). 원인은 R-8.26의 "`paper` 기준 산정" 원칙을 **본 계획자가 라이트 테마에만 적용한 오류**로, v1.11 §6.5와 같은 형태의 누락이다. 게이트도 AC-44 정규식의 **부정 후방탐색 `(?<!dark:)`로 다크 절반을 설계상 면제**하고 있었다(**G-7** — G-1·G-2·G-3에 이은 네 번째 "검사를 절반만 한다" 유형). **처방을 토큰 조정에서 `dark:` 변형 제거로 재설계** — `-dark`는 반전 토큰이므로 `dark:` 없이 4개 셀 전부(5.26/4.76/6.57/5.96)를 통과한다. **R-8.29 강화**(강조색 `dark:` 금지 · 정본 패턴 3종 · 현행 위반 **38건**), **R-8.26·AC-43·AC-44·AC-48(C11-9)·AC-49(F10·F11, 총 11건) 개정**. **D-14 신설**(🔴 `--cobalt` 반전 1.00 — A안 브랜드 고정 예외 권고 / B안 대칭 상향 정정 수치 `#A8BCFF`+`#C7D4FF`). A안은 본 계획자가 작성한 R-8.28의 완화를 포함하므로 **cline·GM 별도 심사 대상**으로 명시했고, 안전망 유지 근거로 `#1B3A8A` 역주입 시 AC-43 즉시 실패(1.80)를 제시했다. Job `d35bc3e7` |
|
||||
| 2.0 | 2026-08-24 | claude (planner) / agy (creator) | **실시간 동적 런타임 페칭 아키텍처 도입 (DM-03·AC-17·R-08 개정)**. Go/Gin 백엔드 REST API(`http://localhost:8080/api/v1`) 연동 및 `force-dynamic` + `cache: 'no-store'` 적용으로 전 페이지 실시간 데이터 바인딩 지원. 6개 데이터 라우트를 `ƒ (Dynamic)`으로, 4개 프레임워크 라우트를 `○ (Static)`으로 분리. 기존 `content/*.ts` 정적 상수를 장애 시 그레이스풀 폴백 데이터셋으로 유지. E2E 테스트 게이트 2, 4, 7, 8을 라이브 서버 기반 검증으로 리팩터링. Job `258f6ff7` |
|
||||
| 3.0 | 2026-08-25 | agy (creator) / claude (planner) | **단일 통합 Go 백엔드 서빙 아키텍처 (Unified Single Go Backend Serving Architecture) 마이그레이션 완료**. Next.js 정적 익스포트(`output: "export"`, `trailingSlash: true`) 및 클라이언트 사이드 데이터 페칭(CSR `useEffect` + 동일 오리진 상대 경로 `/api/v1` 기본값) 도입. 전 공용 라우트(`○`/`●`) 정적 HTML 셸 및 `/app/web` 번들링. Go Gin 라우터에 정적 에셋 서빙(`/_next`, `/robots.txt`, `/sitemap.xml` 등), `/intro` 308 영구 리다이렉트, 및 `NoRoute` 정적 HTML/SPA 404 폴백 핸들러 구현. 3단계 통합 Multi-stage `backend/Dockerfile`(Node 빌드 + Go 빌드 + 최소 Alpine 런타임) 및 단일 서비스 `anl-app` `docker-compose.yml` 구축. 전 품질 게이트(Gate 0~11) 100% PASS 검증. Job `d2035abb` |
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/export-content
|
||||
/api
|
||||
/seed
|
||||
/web/
|
||||
|
||||
# SQLite databases and WAL journal files
|
||||
*.db
|
||||
|
||||
+25
-30
@@ -1,51 +1,47 @@
|
||||
# ==============================================================================
|
||||
# Stage 1: Build the Go Binaries
|
||||
# Stage 1: Build the static frontend export
|
||||
# ==============================================================================
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY refer_landing_page/package.json refer_landing_page/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY refer_landing_page/ .
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Build the Go binaries
|
||||
# ==============================================================================
|
||||
FROM golang:1.26-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install git and certificates
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# Download dependencies
|
||||
COPY go.mod go.sum ./
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy backend source code
|
||||
COPY . .
|
||||
|
||||
# Build pure-Go static binaries
|
||||
COPY backend/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api ./cmd/api && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/seed ./cmd/seed && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/export-content ./cmd/export-content
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Minimal Runtime Image
|
||||
# Stage 3: Minimal Alpine runtime — single container, single process
|
||||
# ==============================================================================
|
||||
FROM alpine:3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates and sqlite cli for maintenance
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
|
||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
|
||||
# Create persistent database storage directory
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
|
||||
|
||||
# Copy built binaries from builder
|
||||
COPY --from=builder /bin/api /app/bin/api
|
||||
COPY --from=builder /bin/seed /app/bin/seed
|
||||
COPY --from=builder /bin/export-content /app/bin/export-content
|
||||
COPY --from=backend-builder /bin/api /app/bin/api
|
||||
COPY --from=backend-builder /bin/seed /app/bin/seed
|
||||
COPY --from=backend-builder /bin/export-content /app/bin/export-content
|
||||
COPY --from=backend-builder /app/seed /app/seed
|
||||
COPY --from=backend-builder /app/.env.example /app/.env
|
||||
COPY --from=frontend-builder --chown=appuser:appgroup /app/frontend/out /app/web
|
||||
|
||||
# Copy seed data
|
||||
COPY --from=builder /app/seed /app/seed
|
||||
|
||||
# Copy environment template
|
||||
COPY --from=builder /app/.env.example /app/.env
|
||||
|
||||
# Set environment defaults for container
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
DB_PATH=/app/data/anl.db \
|
||||
@@ -54,7 +50,6 @@ ENV HOST=0.0.0.0 \
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER appuser
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||
@@ -37,7 +38,7 @@ func main() {
|
||||
cfg.GinMode = *ginModeFlag
|
||||
}
|
||||
if *adminTokenFlag != "" {
|
||||
cfg.AdminToken = *adminTokenFlag
|
||||
cfg.AdminToken = strings.Trim(*adminTokenFlag, " \"'\r\n\t")
|
||||
}
|
||||
|
||||
if cfg.AdminToken == "" {
|
||||
|
||||
@@ -94,7 +94,7 @@ func Load() *Config {
|
||||
}
|
||||
|
||||
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
||||
cfg.AdminToken = token
|
||||
cfg.AdminToken = strings.Trim(token, " \"'\r\n\t")
|
||||
}
|
||||
|
||||
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
|
||||
// RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token.
|
||||
func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
||||
expectedToken := strings.Trim(adminToken, " \"'\r\n\t")
|
||||
return func(c *gin.Context) {
|
||||
if adminToken == "" {
|
||||
if expectedToken == "" {
|
||||
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -23,7 +24,7 @@ func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != adminToken {
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) != expectedToken {
|
||||
Unauthorized(c, "Invalid admin bearer token")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -29,7 +29,7 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var projects []models.ResearchProject
|
||||
projects := make([]models.ResearchProject, 0)
|
||||
for rows.Next() {
|
||||
var p models.ResearchProject
|
||||
var kwJSON string
|
||||
@@ -115,7 +115,7 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var areas []models.ResearchArea
|
||||
areas := make([]models.ResearchArea, 0)
|
||||
for rows.Next() {
|
||||
var a models.ResearchArea
|
||||
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()
|
||||
|
||||
var semesters []models.SemesterWithCourses
|
||||
semesters := make([]models.SemesterWithCourses, 0)
|
||||
semMap := make(map[int]int)
|
||||
|
||||
for semRows.Next() {
|
||||
|
||||
@@ -28,7 +28,7 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []models.Member
|
||||
members := make([]models.Member, 0)
|
||||
for rows.Next() {
|
||||
var m models.Member
|
||||
if err := rows.Scan(
|
||||
@@ -94,7 +94,7 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alumni []models.Alumnus
|
||||
alumni := make([]models.Alumnus, 0)
|
||||
for rows.Next() {
|
||||
var a models.Alumnus
|
||||
if err := rows.Scan(
|
||||
|
||||
@@ -34,7 +34,7 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pubs []models.Publication
|
||||
pubs := make([]models.Publication, 0)
|
||||
for rows.Next() {
|
||||
var p models.Publication
|
||||
if err := rows.Scan(
|
||||
@@ -101,7 +101,7 @@ func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Pu
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pubs []models.Publication
|
||||
pubs := make([]models.Publication, 0)
|
||||
for rows.Next() {
|
||||
var p models.Publication
|
||||
if err := rows.Scan(
|
||||
@@ -127,7 +127,7 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var patents []models.Patent
|
||||
patents := make([]models.Patent, 0)
|
||||
for rows.Next() {
|
||||
var p models.Patent
|
||||
if err := rows.Scan(
|
||||
|
||||
@@ -28,7 +28,7 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
}
|
||||
defer bodyRows.Close()
|
||||
|
||||
var bodies []models.StandardsBodyWithProjects
|
||||
bodies := make([]models.StandardsBodyWithProjects, 0)
|
||||
bodyMap := make(map[int]int)
|
||||
|
||||
for bodyRows.Next() {
|
||||
|
||||
@@ -3,6 +3,9 @@ package router
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/handlers"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
@@ -127,5 +130,54 @@ func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engi
|
||||
admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument)
|
||||
}
|
||||
|
||||
// 308 Permanent Redirect for /intro -> / (H-11 / IA-02)
|
||||
r.GET("/intro", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusPermanentRedirect, "/")
|
||||
})
|
||||
|
||||
// Static assets serving
|
||||
r.Static("/_next", "./web/_next")
|
||||
r.StaticFile("/favicon.ico", "./web/favicon.ico")
|
||||
r.StaticFile("/robots.txt", "./web/robots.txt")
|
||||
r.StaticFile("/sitemap.xml", "./web/sitemap.xml")
|
||||
r.StaticFile("/icon.svg", "./web/icon.svg")
|
||||
r.Static("/fonts", "./web/fonts")
|
||||
|
||||
// NoRoute fallback for static exported HTML & 404
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Never let a mistyped/unknown API path fall through to HTML.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
httpx.NotFound(c, "Route not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Try an exact static file (e.g. an asset with an extension)
|
||||
candidate := filepath.Join("./web", filepath.Clean(path))
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
c.File(candidate)
|
||||
return
|
||||
}
|
||||
|
||||
// Try <path>/index.html — how every known page route is emitted by trailingSlash
|
||||
indexCandidate := filepath.Join("./web", filepath.Clean(path), "index.html")
|
||||
if _, err := os.Stat(indexCandidate); err == nil {
|
||||
c.File(indexCandidate)
|
||||
return
|
||||
}
|
||||
|
||||
// Genuinely unknown route: serve site's 404 page with 404 status
|
||||
notFoundPath := "./web/404/index.html"
|
||||
if _, err := os.Stat(notFoundPath); err != nil {
|
||||
notFoundPath = "./web/404.html"
|
||||
}
|
||||
if notFoundBytes, err := os.ReadFile(notFoundPath); err == nil {
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", notFoundBytes)
|
||||
} else {
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
}
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -521,6 +521,47 @@ func TestCascadeDeletes(t *testing.T) {
|
||||
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
|
||||
}
|
||||
|
||||
func TestStaticServingAndRedirects(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. /intro 308 permanent redirect to /
|
||||
wIntro := doRequest(r, "GET", "/intro", nil, "")
|
||||
assert.Equal(t, http.StatusPermanentRedirect, wIntro.Code)
|
||||
assert.Equal(t, "/", wIntro.Header().Get("Location"))
|
||||
|
||||
// 2. Unknown API route returns 404 JSON (not HTML)
|
||||
wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wAPI404.Code)
|
||||
var apiErr map[string]any
|
||||
require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr))
|
||||
assert.Contains(t, apiErr, "error")
|
||||
|
||||
// 3. Create mock web directory to verify static HTML serving
|
||||
webDir := "./web"
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "test-page"), 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html>Home</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("<html>Test Page</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("<html>404 Not Found</html>"), 0644))
|
||||
defer os.RemoveAll(webDir)
|
||||
|
||||
// Test root URL /
|
||||
wRoot := doRequest(r, "GET", "/", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wRoot.Code)
|
||||
assert.Contains(t, wRoot.Body.String(), "<html>Home</html>")
|
||||
|
||||
// Test sub-route /test-page
|
||||
wTestPage := doRequest(r, "GET", "/test-page", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wTestPage.Code)
|
||||
assert.Contains(t, wTestPage.Body.String(), "<html>Test Page</html>")
|
||||
|
||||
// Test unknown page -> 404 HTML
|
||||
wUnknown := doRequest(r, "GET", "/completely-unknown-route", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wUnknown.Code)
|
||||
assert.Contains(t, wUnknown.Body.String(), "<html>404 Not Found</html>")
|
||||
}
|
||||
|
||||
func strconvItoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
+7
-39
@@ -1,14 +1,16 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
anl-backend:
|
||||
anl-app:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: anl-backend-api
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-}
|
||||
container_name: anl-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8080
|
||||
@@ -19,8 +21,6 @@ services:
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set -- see .env.example}
|
||||
volumes:
|
||||
- anl-db-data:/app/data
|
||||
networks:
|
||||
- anl-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"]
|
||||
interval: 15s
|
||||
@@ -28,38 +28,6 @@ services:
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
anl-frontend:
|
||||
build:
|
||||
context: ./refer_landing_page
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8080/api/v1}
|
||||
container_name: anl-frontend-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- HOSTNAME=0.0.0.0
|
||||
- API_BASE_URL=http://anl-backend:8080/api/v1
|
||||
depends_on:
|
||||
anl-backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- anl-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
anl-db-data:
|
||||
name: anl-sqlite-storage
|
||||
|
||||
networks:
|
||||
anl-net:
|
||||
name: anl-network
|
||||
driver: bridge
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.mam
|
||||
scripts
|
||||
tests
|
||||
coverage
|
||||
tsconfig.tsbuildinfo
|
||||
.env*.local
|
||||
@@ -1,59 +0,0 @@
|
||||
# ==============================================================================
|
||||
# Stage 1: Install Dependencies
|
||||
# ==============================================================================
|
||||
FROM node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Build Application
|
||||
# ==============================================================================
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build-time argument for inlining client-side API URL into Next.js bundle
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 3: Production Runner
|
||||
# ==============================================================================
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy static assets and public directories
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set permissions for prerender cache
|
||||
RUN mkdir .next && chown nextjs:nodejs .next
|
||||
|
||||
# Copy standalone build output and static assets
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import type { ResearchProject } from "../../content/types";
|
||||
import ProjectPageViewControls from "./ProjectPageViewControls";
|
||||
|
||||
@@ -13,22 +14,24 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Key Initiatives
|
||||
</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Key Initiatives
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Research Projects
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Research Projects
|
||||
</h2>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
{projects.length} core international standardization research projects
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
{projects.length} core international standardization research projects
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
|
||||
<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"
|
||||
>
|
||||
{projects.map((proj, idx) => (
|
||||
<article
|
||||
<Reveal
|
||||
key={proj.slug}
|
||||
as="article"
|
||||
id={`project-${proj.slug}`}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
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">
|
||||
{/* Header Info */}
|
||||
@@ -124,7 +130,7 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ export function removeClientToken() {
|
||||
|
||||
export function AdminHeader({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-ink tracking-tight">{title}</h1>
|
||||
{description && <p className="text-sm text-ink/70 mt-1">{description}</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +4,9 @@ import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
|
||||
import { AdminLoginForm } from "./_components/AdminLoginForm";
|
||||
import { adminVerifyToken } from "../../lib/adminApi";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
@@ -11,26 +14,69 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getClientToken();
|
||||
if (!token && pathname !== "/console/login") {
|
||||
router.push("/console/login");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
let isMounted = true;
|
||||
|
||||
async function checkAuth() {
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
if (isMounted) setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await adminVerifyToken(token);
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!isValid) {
|
||||
removeClientToken();
|
||||
setIsAuthenticated(false);
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
checkAuth();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
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) {
|
||||
const token = typeof window !== "undefined" ? getClientToken() : null;
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
|
||||
<AdminLoginForm onLoginSuccess={() => setIsAuthenticated(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center">
|
||||
<div className="min-h-screen bg-ivory text-ink flex flex-col items-center justify-center space-y-4">
|
||||
<div className="text-sm text-ink/70">Checking authentication...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = [
|
||||
{ href: "/console", label: "Dashboard Overview" },
|
||||
{ href: "/console/research-projects", label: "Research Projects" },
|
||||
@@ -43,32 +89,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
const handleLogout = () => {
|
||||
removeClientToken();
|
||||
router.push("/console/login");
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex flex-col md:flex-row">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-full md:w-64 bg-paper border-r border-line flex flex-col shrink-0">
|
||||
<div className="p-6 border-b border-line">
|
||||
{/* Fixed Sidebar */}
|
||||
<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 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
||||
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
||||
<p className="text-xs text-ink/60 mt-0.5">Lab Data Management</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<ThemeToggle />
|
||||
<span className="text-[10px] bg-cobalt/10 text-cobalt font-medium px-1.5 py-0.5 rounded leading-none">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-ink/60 mt-1">Lab Data Management</p>
|
||||
</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) => {
|
||||
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 (
|
||||
<Link
|
||||
key={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
|
||||
? "bg-cobalt text-white"
|
||||
: "text-ink/80 hover:bg-ivory hover:text-ink"
|
||||
? "bg-cobalt text-white shadow-sm font-semibold"
|
||||
: "text-ink/80 hover:bg-cobalt/10 hover:text-cobalt dark:hover:bg-cobalt/20 dark:hover:text-cobalt-light"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
@@ -77,10 +136,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
})}
|
||||
</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
|
||||
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
|
||||
</Link>
|
||||
@@ -93,8 +152,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 p-6 md:p-10 max-w-7xl overflow-x-auto">{children}</main>
|
||||
{/* Main Content Area with offset for fixed sidebar */}
|
||||
<main className="flex-1 md:ml-64 p-6 md:p-10 min-h-screen overflow-x-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setClientToken } from "../_components/AdminComponents";
|
||||
import { adminVerifyToken } from "../../../lib/adminApi";
|
||||
import React from "react";
|
||||
import { AdminLoginForm } from "../_components/AdminLoginForm";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
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());
|
||||
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>
|
||||
);
|
||||
return <AdminLoginForm />;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AdminHeader, AlertBanner } from "./_components/AdminComponents";
|
||||
import {
|
||||
adminGetStatsSummary,
|
||||
adminGetResearchProjects,
|
||||
adminGetResearchAreas,
|
||||
adminGetMembers,
|
||||
@@ -13,11 +12,9 @@ import {
|
||||
adminGetPatents,
|
||||
adminGetSemesters,
|
||||
adminGetStandardsBodies,
|
||||
AdminStatsSummary,
|
||||
} from "../../lib/adminApi";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<AdminStatsSummary | null>(null);
|
||||
const [counts, setCounts] = useState<{
|
||||
projects: number;
|
||||
areas: number;
|
||||
@@ -51,7 +48,6 @@ export default function AdminDashboardPage() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [
|
||||
statsData,
|
||||
projects,
|
||||
areas,
|
||||
members,
|
||||
@@ -62,7 +58,6 @@ export default function AdminDashboardPage() {
|
||||
semesters,
|
||||
bodies,
|
||||
] = await Promise.all([
|
||||
adminGetStatsSummary().catch(() => null),
|
||||
adminGetResearchProjects().catch(() => []),
|
||||
adminGetResearchAreas().catch(() => []),
|
||||
adminGetMembers().catch(() => []),
|
||||
@@ -74,25 +69,33 @@ export default function AdminDashboardPage() {
|
||||
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 totalDocs = bodies.reduce(
|
||||
(acc, b) => acc + (b.projects?.reduce((pAcc, p) => pAcc + (p.documents?.length || 0), 0) || 0),
|
||||
const totalCourses = safeSemesters.reduce((acc, s) => acc + (s?.courses?.length || 0), 0);
|
||||
const totalDocs = safeBodies.reduce(
|
||||
(acc, b) => acc + ((b?.projects || []).reduce((pAcc, p) => pAcc + (p?.documents?.length || 0), 0) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
setCounts({
|
||||
projects: projects.length,
|
||||
areas: areas.length,
|
||||
members: members.length,
|
||||
alumni: alumni.length,
|
||||
intlPubs: intlPubs.length,
|
||||
domPubs: domPubs.length,
|
||||
patents: patents.length,
|
||||
semesters: semesters.length,
|
||||
projects: safeProjects.length,
|
||||
areas: safeAreas.length,
|
||||
members: safeMembers.length,
|
||||
alumni: safeAlumni.length,
|
||||
intlPubs: safeIntlPubs.length,
|
||||
domPubs: safeDomPubs.length,
|
||||
patents: safePatents.length,
|
||||
semesters: safeSemesters.length,
|
||||
courses: totalCourses,
|
||||
bodies: bodies.length,
|
||||
bodies: safeBodies.length,
|
||||
docs: totalDocs,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function AdminPublicationsPage() {
|
||||
const [publications, setPublications] = useState<AdminPublication[]>([]);
|
||||
const [patents, setPatents] = useState<AdminPatent[]>([]);
|
||||
const [filterCategory, setFilterCategory] = useState<string>("all");
|
||||
const [filterYear, setFilterYear] = useState<string>("all");
|
||||
const [filterPatentYear, setFilterPatentYear] = useState<string>("all");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
@@ -248,10 +250,44 @@ export default function AdminPublicationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPubs =
|
||||
filterCategory === "all"
|
||||
? publications
|
||||
: publications.filter((p) => p.category === filterCategory);
|
||||
// Extract unique sorted years from publications
|
||||
const pubYears = Array.from(
|
||||
new Set(
|
||||
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 (
|
||||
<div>
|
||||
@@ -296,21 +332,66 @@ export default function AdminPublicationsPage() {
|
||||
) : tab === "publications" ? (
|
||||
<div>
|
||||
{/* Filter sub-bar */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-xs font-semibold text-ink/70">Filter:</span>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">All Categories ({publications.length})</option>
|
||||
<option value="intl-journal-conf">
|
||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
|
||||
</option>
|
||||
<option value="domestic-journal-conf">
|
||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
|
||||
</option>
|
||||
</select>
|
||||
<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">Category:</span>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">
|
||||
All Categories ({publications.filter((p) => filterYear === "all" || (p.published_at || "").includes(filterYear)).length})
|
||||
</option>
|
||||
<option value="intl-journal-conf">
|
||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
|
||||
</option>
|
||||
<option value="domestic-journal-conf">
|
||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Year:</span>
|
||||
<select
|
||||
value={filterYear}
|
||||
onChange={(e) => setFilterYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">
|
||||
All Years ({publications.filter((p) => filterCategory === "all" || p.category === filterCategory).length})
|
||||
</option>
|
||||
{pubYears.map((yr) => {
|
||||
const count = publications.filter((p) => {
|
||||
const matchCat = filterCategory === "all" || p.category === filterCategory;
|
||||
const matchYr = (p.published_at || "").includes(yr);
|
||||
return matchCat && matchYr;
|
||||
}).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(filterCategory !== "all" || filterYear !== "all") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterCategory("all");
|
||||
setFilterYear("all");
|
||||
}}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-ink/50 ml-auto">
|
||||
Showing {filteredPubs.length} of {publications.length} records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
@@ -379,25 +460,64 @@ export default function AdminPublicationsPage() {
|
||||
</div>
|
||||
) : (
|
||||
/* Patents Table */
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-28">Date</th>
|
||||
<th className="p-4">Title & Inventors</th>
|
||||
<th className="p-4">App / Reg Numbers</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{patents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No patents found.
|
||||
</td>
|
||||
<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">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-28">Date</th>
|
||||
<th className="p-4">Title & Inventors</th>
|
||||
<th className="p-4">App / Reg Numbers</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
) : (
|
||||
patents.map((p) => (
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{filteredPatents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No patents found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPatents.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
|
||||
<td className="p-4">
|
||||
@@ -432,6 +552,7 @@ export default function AdminPublicationsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pub Modal */}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Lectures",
|
||||
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
|
||||
};
|
||||
|
||||
export default function LecturesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { getSemesters } from "../../lib/api";
|
||||
import type { Semester } from "../../content/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Lectures",
|
||||
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
|
||||
};
|
||||
export default function LecturesPage() {
|
||||
const [semesters, setSemesters] = useState<Semester[]>([]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LecturesPage() {
|
||||
const semesters = (await getSemesters()) ?? [];
|
||||
useEffect(() => {
|
||||
getSemesters().then((s) => {
|
||||
if (s) setSemesters(s);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const recentSemesters = semesters.slice(0, 3);
|
||||
const olderSemesters = semesters.slice(3);
|
||||
@@ -22,110 +24,124 @@ export default async function LecturesPage() {
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
LECTURES
|
||||
</h1>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
LECTURES
|
||||
</h1>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Recent 3 Semesters */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Recent Courses
|
||||
</h2>
|
||||
<span className="font-mono text-xs text-ink-mute hidden sm:inline">
|
||||
Latest 3 Semesters
|
||||
</span>
|
||||
</div>
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Recent Courses
|
||||
</h2>
|
||||
<span className="font-mono text-xs text-ink-mute hidden sm:inline">
|
||||
Latest 3 Semesters
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-6">
|
||||
{recentSemesters.map((sem, idx) => (
|
||||
<div
|
||||
<Reveal
|
||||
key={idx}
|
||||
className="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm"
|
||||
variant="up"
|
||||
delay={idx * 80}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-line pb-2.5">
|
||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||
{sem.term} {sem.year}
|
||||
</span>
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10">
|
||||
Latest
|
||||
</span>
|
||||
<div
|
||||
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="flex justify-between items-center border-b border-line pb-2.5">
|
||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||
{sem.term} {sem.year}
|
||||
</span>
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10">
|
||||
Latest
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 pt-1">
|
||||
{sem.courses.map((c, cIdx) => (
|
||||
<li key={cIdx} className="space-y-0.5">
|
||||
<div className="font-serif text-sm sm:text-base text-ink leading-snug">
|
||||
{c.en}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">
|
||||
Code: {c.code} {c.note && `(${c.note})`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 pt-1">
|
||||
{sem.courses.map((c, cIdx) => (
|
||||
<li key={cIdx} className="space-y-0.5">
|
||||
<div className="font-serif text-sm sm:text-base text-ink leading-snug">
|
||||
{c.en}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">
|
||||
Code: {c.code} {c.note && `(${c.note})`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
|
||||
<span>Total Offerings</span>
|
||||
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
|
||||
<span>Total Offerings</span>
|
||||
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Older Semesters (Native <details>/<summary> progressive disclosure) */}
|
||||
<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">
|
||||
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
||||
<div className="space-y-1">
|
||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||
Browse all semesters
|
||||
</span>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
|
||||
<span className="group-open:hidden">Open ▾</span>
|
||||
<span className="hidden group-open:inline">Close ▴</span>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||||
{olderSemesters.map((sem, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
|
||||
>
|
||||
<div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
|
||||
<span>
|
||||
{sem.term} {sem.year}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-mute">
|
||||
{sem.courses.length} Courses
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||
Browse all semesters
|
||||
</span>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
|
||||
<span className="group-open:hidden">Open ▾</span>
|
||||
<span className="hidden group-open:inline">Close ▴</span>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{sem.courses.map((c, cIdx) => (
|
||||
<li key={cIdx} className="space-y-0.5">
|
||||
<div className="font-serif text-sm text-ink leading-snug">
|
||||
{c.en}
|
||||
</div>
|
||||
<div className="font-mono text-[0.7rem] text-ink-mute">
|
||||
Code: {c.code} {c.note && `(${c.note})`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||||
{olderSemesters.map((sem, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
|
||||
>
|
||||
<div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
|
||||
<span>
|
||||
{sem.term} {sem.year}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-mute">
|
||||
{sem.courses.length} Courses
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{sem.courses.map((c, cIdx) => (
|
||||
<li key={cIdx} className="space-y-0.5">
|
||||
<div className="font-serif text-sm text-ink leading-snug">
|
||||
{c.en}
|
||||
</div>
|
||||
<div className="font-mono text-[0.7rem] text-ink-mute">
|
||||
Code: {c.code} {c.note && `(${c.note})`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Members",
|
||||
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
|
||||
};
|
||||
|
||||
export default function MembersLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { getMembers, getAlumni } from "../../lib/api";
|
||||
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Members",
|
||||
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import type { Member, Alumnus } from "../../content/types";
|
||||
|
||||
const DEGREE_LABEL: Record<string, string> = {
|
||||
Professor: "Professor",
|
||||
@@ -20,9 +17,18 @@ const DEGREE_LABEL: Record<string, string> = {
|
||||
MS: "M. S.",
|
||||
};
|
||||
|
||||
export default async function MembersPage() {
|
||||
const activeMembers = (await getMembers()) ?? [];
|
||||
const alumni = (await getAlumni()) ?? [];
|
||||
export default function MembersPage() {
|
||||
const [activeMembers, setActiveMembers] = useState<Member[]>([]);
|
||||
const [alumni, setAlumni] = useState<Alumnus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getMembers().then((m) => {
|
||||
if (m) setActiveMembers(m);
|
||||
});
|
||||
getAlumni().then((a) => {
|
||||
if (a) setAlumni(a);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const phdStudents = activeMembers.filter(
|
||||
(m) =>
|
||||
@@ -38,121 +44,147 @@ export default async function MembersPage() {
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
LAB MEMBERS
|
||||
</h1>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
LAB MEMBERS
|
||||
</h1>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Professor Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||
<div className="lg:col-span-8 space-y-4">
|
||||
<h2 className="font-serif text-3xl font-normal text-ink">
|
||||
Prof. Seok-Joo Koh
|
||||
</h2>
|
||||
<div className="text-sm text-ink-mute leading-relaxed space-y-2">
|
||||
<p>
|
||||
Professor at the School of Computer Science and Engineering, Kyungpook National University, and Dean of the College of IT Engineering. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP & SKILL architectures, and multi-agent orchestration, and serves as Project Editor in ISO/IEC JTC1, ITU-T, and IEC TC100.
|
||||
</p>
|
||||
<div>
|
||||
<ProfessorDetailsDialog />
|
||||
<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="lg:col-span-8 space-y-4">
|
||||
<h2 className="font-serif text-3xl font-normal text-ink">
|
||||
Prof. Seok-Joo Koh
|
||||
</h2>
|
||||
<div className="text-sm text-ink-mute leading-relaxed space-y-2">
|
||||
<p>
|
||||
Professor at the School of Computer Science and Engineering, Kyungpook National University, and Dean of the College of IT Engineering. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP & SKILL architectures, and multi-agent orchestration, and serves as Project Editor in ISO/IEC JTC1, ITU-T, and IEC TC100.
|
||||
</p>
|
||||
<div>
|
||||
<ProfessorDetailsDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono">
|
||||
<a
|
||||
href="mailto:sjkoh@knu.ac.kr"
|
||||
className="text-cobalt-dark hover:underline font-bold"
|
||||
>
|
||||
sjkoh@knu.ac.kr
|
||||
</a>
|
||||
<span className="text-ink-mute">|</span>
|
||||
<a
|
||||
href="https://github.com/sjkoh12"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink hover:text-cobalt-dark hover:underline"
|
||||
>
|
||||
GitHub Profile ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono">
|
||||
<a
|
||||
href="mailto:sjkoh@knu.ac.kr"
|
||||
className="text-cobalt-dark hover:underline font-bold"
|
||||
>
|
||||
sjkoh@knu.ac.kr
|
||||
</a>
|
||||
<span className="text-ink-mute">|</span>
|
||||
<a
|
||||
href="https://github.com/sjkoh12"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink hover:text-cobalt-dark hover:underline"
|
||||
>
|
||||
GitHub Profile ↗
|
||||
</a>
|
||||
<div className="lg:col-span-4 flex justify-center items-center">
|
||||
<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">
|
||||
<Image
|
||||
src="/images/prof_profile.png"
|
||||
alt="Prof. Seok-Joo Koh"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 224px, 240px"
|
||||
className="object-cover object-top"
|
||||
priority
|
||||
/>
|
||||
</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="text-ink font-bold border-b border-line pb-1">
|
||||
Affiliation & Office
|
||||
</div>
|
||||
<p className="text-ink-mute">Kyungpook National University</p>
|
||||
<p className="text-ink-mute">IT Building #5 Room 527</p>
|
||||
<p className="text-ink-mute">Daegu, Republic of Korea</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Active Researchers Section */}
|
||||
<section className="space-y-8 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Active Researchers
|
||||
</h2>
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Active Researchers
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Ph.D. Candidates / Students */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||
Ph.D. Course ({phdStudents.length})
|
||||
</h3>
|
||||
<Reveal variant="up">
|
||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||
Ph.D. Course ({phdStudents.length})
|
||||
</h3>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{phdStudents.map((mem, idx) => (
|
||||
<div
|
||||
<Reveal
|
||||
key={idx}
|
||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-serif text-lg font-normal text-ink">
|
||||
{mem.ko}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-cobalt-dark">
|
||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||
{mem.affiliation && (
|
||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||
with {mem.affiliation}
|
||||
<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">
|
||||
<span className="font-serif text-lg font-normal text-ink">
|
||||
{mem.ko}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-cobalt-dark">
|
||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||
{mem.affiliation && (
|
||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||
with {mem.affiliation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M.S. Candidates / Students */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||
M.S. Course ({msStudents.length})
|
||||
</h3>
|
||||
<Reveal variant="up">
|
||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||
M.S. Course ({msStudents.length})
|
||||
</h3>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{msStudents.map((mem, idx) => (
|
||||
<div
|
||||
<Reveal
|
||||
key={idx}
|
||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-serif text-lg font-normal text-ink">
|
||||
{mem.ko}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-cobalt-dark">
|
||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||
{mem.affiliation && (
|
||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||
with {mem.affiliation}
|
||||
<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">
|
||||
<span className="font-serif text-lg font-normal text-ink">
|
||||
{mem.ko}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-cobalt-dark">
|
||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||
{mem.affiliation && (
|
||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||
with {mem.affiliation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,13 +192,15 @@ export default async function MembersPage() {
|
||||
|
||||
{/* Alumni Section (Native <details>/<summary> progressive disclosure) */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Graduates
|
||||
</h2>
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Graduates
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<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">
|
||||
|
||||
+145
-109
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import Counter from "@/components/Counter";
|
||||
import HeroComposition from "./_components/HeroComposition";
|
||||
import { ProjectPageView } from "./_components/ProjectPageView";
|
||||
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import type { ResearchProject } from "../content/types";
|
||||
|
||||
const INTERNATIONAL_SDOS = [
|
||||
"IEC TC100: Multimedia Systems and Equipment",
|
||||
@@ -14,71 +17,92 @@ const INTERNATIONAL_SDOS = [
|
||||
"ISO/IEC JTC1/SC6: Reliable Multicasting",
|
||||
];
|
||||
|
||||
export default async function HomePage() {
|
||||
const stats = (await getStatsSummary()) ?? {
|
||||
export default function HomePage() {
|
||||
const [stats, setStats] = useState({
|
||||
intlPubsCount: 0,
|
||||
patentCount: 0,
|
||||
stdDocsCount: 0,
|
||||
};
|
||||
});
|
||||
const [researchAreas, setResearchAreas] = useState<string[]>([]);
|
||||
const [researchProjects, setResearchProjects] = useState<ResearchProject[]>([]);
|
||||
|
||||
const researchAreas = (await getResearchAreaNames()) ?? [];
|
||||
const researchProjects = (await getResearchProjects()) ?? [];
|
||||
useEffect(() => {
|
||||
getStatsSummary().then((s) => {
|
||||
if (s) setStats(s);
|
||||
});
|
||||
getResearchAreaNames().then((areas) => {
|
||||
if (areas) setResearchAreas(areas);
|
||||
});
|
||||
getResearchProjects().then((projs) => {
|
||||
if (projs) setResearchProjects(projs);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
{/* Hero Section */}
|
||||
<section className="relative">
|
||||
<div className="mb-4">
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Kyungpook National University · AI Agent Networking Lab (ANL)
|
||||
</span>
|
||||
</div>
|
||||
<Reveal variant="up">
|
||||
<div className="mb-4">
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Kyungpook National University · AI Agent Networking Lab (ANL)
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<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">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
||||
Multi-Agent <br />
|
||||
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
||||
</h1>
|
||||
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl">
|
||||
The AI Agent Networking Laboratory (ANL) studies agent networking technologies and standard frameworks that enable AI agents to communicate and collaborate with one another for multi-agent orchestration.
|
||||
</p>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
||||
Multi-Agent <br />
|
||||
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
||||
</h1>
|
||||
<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.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Metrics Row (Derived from real content data) */}
|
||||
<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">
|
||||
{stats.intlPubsCount}
|
||||
<Reveal variant="up" delay={80}>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
<Counter value={stats.intlPubsCount} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
International Journals & Conference
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
International Journals & Conference
|
||||
</Reveal>
|
||||
<Reveal variant="up" delay={160}>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
<Counter value={stats.stdDocsCount} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
Standardization Contributions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
{stats.stdDocsCount}
|
||||
</Reveal>
|
||||
<Reveal variant="up" delay={240}>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
<Counter value={stats.patentCount} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
Patent<br />
|
||||
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
|
||||
(IoT Architecture & Protocols)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
Standardization Contributions
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
{stats.patentCount}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
Patent<br />
|
||||
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
|
||||
(IoT Architecture & Protocols)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</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 />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -87,22 +111,24 @@ export default async function HomePage() {
|
||||
|
||||
{/* 14 Research Areas Grid */}
|
||||
<section className="space-y-8 pt-16 md:pt-20 border-t border-line">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Core Domains
|
||||
</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Core Domains
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Research Areas
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Research Areas
|
||||
</h2>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
{researchAreas.length} fundamental networking protocols and AI system domains
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
{researchAreas.length} fundamental networking protocols and AI system domains
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{researchAreas.map((area, idx) => {
|
||||
@@ -110,28 +136,34 @@ export default async function HomePage() {
|
||||
const isOdd = (idx + 1) % 2 !== 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Reveal
|
||||
key={no}
|
||||
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold ${
|
||||
isOdd ? "text-vermillion-dark" : "text-cobalt-dark"
|
||||
}`}
|
||||
>
|
||||
{no}
|
||||
</span>
|
||||
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider">
|
||||
Topic #{no}
|
||||
</span>
|
||||
<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"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold ${
|
||||
isOdd ? "text-vermillion-dark" : "text-cobalt-dark"
|
||||
}`}
|
||||
>
|
||||
{no}
|
||||
</span>
|
||||
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider">
|
||||
Topic #{no}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||
{area}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||
{area}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -139,43 +171,47 @@ export default async function HomePage() {
|
||||
|
||||
{/* International Standardizations Section */}
|
||||
<section className="space-y-6 pt-16 md:pt-20 border-t border-line">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Global Engagement
|
||||
</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Global Engagement
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
International Standardizations
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
International Standardizations
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<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">
|
||||
Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations):
|
||||
</p>
|
||||
<Reveal variant="up">
|
||||
<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">
|
||||
Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations):
|
||||
</p>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2">
|
||||
{INTERNATIONAL_SDOS.map((sdo, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||
<span>{sdo}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2">
|
||||
{INTERNATIONAL_SDOS.map((sdo, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||
<span>{sdo}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="pt-4 border-t border-line/60">
|
||||
<Link
|
||||
href="/standardization"
|
||||
className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline"
|
||||
>
|
||||
<span>Please refer to here for details →</span>
|
||||
</Link>
|
||||
<div className="pt-4 border-t border-line/60">
|
||||
<Link
|
||||
href="/standardization"
|
||||
className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline"
|
||||
>
|
||||
<span>Please refer to here for details →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { CategoryNav } from "../../_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../../../lib/api";
|
||||
import { PUB_CATEGORIES } from "../../../../content/categories";
|
||||
import type { PubCategory, Publication, Patent } from "../../../../content/types";
|
||||
|
||||
export function PublicationCategoryClient({ category }: { category: string }) {
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||
if (!currentCat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = category as PubCategory;
|
||||
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [patents, setPatents] = useState<Patent[]>([]);
|
||||
|
||||
const [selectedYear, setSelectedYear] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
getPublications().then((p) => {
|
||||
if (p) setPublications(p);
|
||||
});
|
||||
getPatents().then((pat) => {
|
||||
if (pat) setPatents(pat);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 allRecords: any[] = [];
|
||||
if (slug === "patent") {
|
||||
allRecords = patents.map((p) => ({
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
appNo: p.applicationNo,
|
||||
appDate: p.applicationAt,
|
||||
regNo: p.registrationNo,
|
||||
regDate: p.registrationAt,
|
||||
country: p.country,
|
||||
publishedAt: p.publishedAt,
|
||||
isPatent: true,
|
||||
}));
|
||||
} else {
|
||||
allRecords = publications
|
||||
.filter((p) => p.category === slug)
|
||||
.map((p) => ({
|
||||
title: p.title,
|
||||
venue: p.venue,
|
||||
volume: p.volume,
|
||||
publishedAt: p.publishedAt,
|
||||
kci: p.kci,
|
||||
isPatent: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section className="space-y-1">
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute mt-1">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Category Nav Cards */}
|
||||
<section className="pt-6 md:pt-8 border-t border-line">
|
||||
<CategoryNav currentCategory={slug} counts={counts} />
|
||||
</section>
|
||||
|
||||
{/* Record List Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<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">
|
||||
Showing {records.length} of {allRecords.length} Records
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||
<p className="font-serif text-lg text-ink">
|
||||
No records found in this category.
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Records will be updated periodically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{records.map((rec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||
{rec.title}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={rec.publishedAt}
|
||||
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
|
||||
>
|
||||
{rec.publishedAt}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{rec.isPatent ? (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink">Inventors: {rec.inventors}</p>
|
||||
<p>
|
||||
App No: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||
</p>
|
||||
{rec.regNo && (
|
||||
<p className="text-cobalt-dark font-bold">
|
||||
Reg No: {rec.regNo} ({rec.regDate})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink font-serif italic">{rec.venue}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{rec.volume}</span>
|
||||
{rec.kci && (
|
||||
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||
KCI Indexed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoryNav } from "../_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../../lib/api";
|
||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||
import { PubCategory } from "../../../content/types";
|
||||
import { PublicationCategoryClient } from "./_components/PublicationCategoryClient";
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -12,6 +8,10 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return PUB_CATEGORIES.map((c) => ({ category: c.slug }));
|
||||
}
|
||||
|
||||
export function generateMetadata({ params }: PageProps): Metadata {
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === params.category);
|
||||
if (!currentCat) {
|
||||
@@ -23,138 +23,6 @@ export function generateMetadata({ params }: PageProps): Metadata {
|
||||
};
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationCategoryPage({ params }: PageProps) {
|
||||
const { category } = params;
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||
|
||||
if (!currentCat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = category as PubCategory;
|
||||
|
||||
const publications = (await getPublications()) ?? [];
|
||||
const patents = (await getPatents()) ?? [];
|
||||
|
||||
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) => ({
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
appNo: p.applicationNo,
|
||||
appDate: p.applicationAt,
|
||||
regNo: p.registrationNo,
|
||||
regDate: p.registrationAt,
|
||||
country: p.country,
|
||||
publishedAt: p.publishedAt,
|
||||
isPatent: true,
|
||||
}));
|
||||
} else {
|
||||
records = publications
|
||||
.filter((p) => p.category === slug)
|
||||
.map((p) => ({
|
||||
title: p.title,
|
||||
venue: p.venue,
|
||||
volume: p.volume,
|
||||
publishedAt: p.publishedAt,
|
||||
kci: p.kci,
|
||||
isPatent: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section className="space-y-1">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Category Nav Cards */}
|
||||
<section className="pt-6 md:pt-8 border-t border-line">
|
||||
<CategoryNav currentCategory={slug} counts={counts} />
|
||||
</section>
|
||||
|
||||
{/* Record List Section */}
|
||||
<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">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
{records.length} Records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||
<p className="font-serif text-lg text-ink">
|
||||
No records found in this category.
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Records will be updated periodically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{records.map((rec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||
{rec.title}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={rec.publishedAt}
|
||||
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
|
||||
>
|
||||
{rec.publishedAt}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{rec.isPatent ? (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink">Inventors: {rec.inventors}</p>
|
||||
<p>
|
||||
App No: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||
</p>
|
||||
{rec.regNo && (
|
||||
<p className="text-cobalt-dark font-bold">
|
||||
Reg No: {rec.regNo} ({rec.regDate})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink font-serif italic">{rec.venue}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{rec.volume}</span>
|
||||
{rec.kci && (
|
||||
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||
KCI Indexed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
export default function PublicationCategoryPage({ params }: PageProps) {
|
||||
return <PublicationCategoryClient category={params.category} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import Counter from "@/components/Counter";
|
||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||
import { PubCategory } from "../../../content/types";
|
||||
|
||||
@@ -24,87 +26,95 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Categories
|
||||
</h2>
|
||||
{currentCategory && (
|
||||
<Link
|
||||
href="/publications"
|
||||
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
||||
>
|
||||
← Overview
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Categories
|
||||
</h2>
|
||||
{currentCategory && (
|
||||
<Link
|
||||
href="/publications"
|
||||
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
||||
>
|
||||
← Overview
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<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 isActive = currentCategory === cat.slug;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Reveal
|
||||
key={cat.slug}
|
||||
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all ${
|
||||
isActive
|
||||
? "border-2 border-cobalt-dark shadow-md"
|
||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||
}`}
|
||||
variant="up"
|
||||
delay={idx * 80}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
||||
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
||||
<div
|
||||
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all h-full ${
|
||||
isActive
|
||||
? "border-2 border-cobalt-dark shadow-md"
|
||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
||||
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
||||
}`}
|
||||
>
|
||||
Category
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={`font-serif text-xl ${
|
||||
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
||||
}`}
|
||||
>
|
||||
Category
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{cat.label}
|
||||
{cat.subtitle && (
|
||||
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
||||
{cat.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={`font-serif text-xl ${
|
||||
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{cat.subtitle && (
|
||||
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
||||
{cat.subtitle}
|
||||
<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">
|
||||
<Counter value={count} />
|
||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
||||
Active View ●
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/publications/${cat.slug}`}
|
||||
aria-label={`View all ${cat.label} records`}
|
||||
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
||||
<span className="font-display text-3xl font-bold text-ink">
|
||||
{count}
|
||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
||||
Active View ●
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/publications/${cat.slug}`}
|
||||
aria-label={`View all ${cat.label} records`}
|
||||
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Publications",
|
||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||
};
|
||||
|
||||
export default function PublicationsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { CategoryNav } from "./_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../lib/api";
|
||||
import type { Publication, Patent } from "../../content/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Publications",
|
||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||
};
|
||||
export default function PublicationsIndexPage() {
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [patents, setPatents] = useState<Patent[]>([]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationsIndexPage() {
|
||||
const publications = (await getPublications()) ?? [];
|
||||
const patents = (await getPatents()) ?? [];
|
||||
useEffect(() => {
|
||||
getPublications().then((p) => {
|
||||
if (p) setPublications(p);
|
||||
});
|
||||
getPatents().then((pat) => {
|
||||
if (pat) setPatents(pat);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||
@@ -26,9 +32,11 @@ export default async function PublicationsIndexPage() {
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
PUBLICATIONS
|
||||
</h1>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
PUBLICATIONS
|
||||
</h1>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Category Summary Cards */}
|
||||
@@ -38,13 +46,21 @@ export default async function PublicationsIndexPage() {
|
||||
|
||||
{/* Highlights Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Highlights
|
||||
</h2>
|
||||
<Reveal variant="up">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Highlights
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm">
|
||||
{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">
|
||||
<span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold">
|
||||
{pub.publishedAt?.substring(0, 7) || "Recent"}
|
||||
@@ -71,7 +87,7 @@ export default async function PublicationsIndexPage() {
|
||||
{pub.venue}{pub.volume ? `, ${pub.volume}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Standardization",
|
||||
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
||||
};
|
||||
|
||||
export default function StandardizationLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,109 +1,119 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { getStandardsBodies } from "../../lib/api";
|
||||
import type { StandardsBody } from "../../content/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Standardization",
|
||||
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
||||
};
|
||||
export default function StandardizationPage() {
|
||||
const [standardsBodies, setStandardsBodies] = useState<StandardsBody[]>([]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StandardizationPage() {
|
||||
const standardsBodies = (await getStandardsBodies()) ?? [];
|
||||
useEffect(() => {
|
||||
getStandardsBodies().then((sb) => {
|
||||
if (sb) setStandardsBodies(sb);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
International Standardization
|
||||
</h1>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
International Standardization
|
||||
</h1>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Standards Bodies Section */}
|
||||
<section className="space-y-8 sm:space-y-10 pt-6 md:pt-8 border-t border-line">
|
||||
{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">
|
||||
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-cobalt-dark font-bold uppercase">
|
||||
[{body.scope}]
|
||||
</span>
|
||||
{body.role && (
|
||||
<span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
|
||||
{body.role}
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-cobalt-dark font-bold uppercase">
|
||||
[{body.scope}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl font-normal text-ink mt-1">
|
||||
{body.org}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-mute font-mono mt-0.5">
|
||||
{body.full} ({body.period})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-xs text-ink-mute bg-ivory border border-line px-3 py-1.5 rounded">
|
||||
Projects: {body.projects.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-Tier Hierarchy: Projects & Documents */}
|
||||
<div className="space-y-6">
|
||||
{body.projects.map((proj, pIdx) => (
|
||||
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
|
||||
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
|
||||
{proj.wg}
|
||||
{body.role && (
|
||||
<span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
|
||||
{body.role}
|
||||
</span>
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-ink-mute">
|
||||
{proj.documents.length} Docs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{proj.documents.length > 0 ? (
|
||||
<div className="space-y-3 pt-1">
|
||||
{proj.documents.map((doc, dIdx) => (
|
||||
<div
|
||||
key={dIdx}
|
||||
className="bg-paper p-4 rounded border border-line/60 space-y-1.5"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||
“{doc.title}”
|
||||
</h3>
|
||||
<span
|
||||
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
|
||||
doc.status === "InProgress"
|
||||
? "bg-vermillion-dark text-ivory"
|
||||
: "bg-cobalt-dark text-ivory"
|
||||
}`}
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute flex justify-between">
|
||||
<span>{doc.ref}</span>
|
||||
{doc.publishedAt && (
|
||||
<span className="text-ink">{doc.publishedAt}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-ink-mute font-mono italic">
|
||||
Project specification in progress
|
||||
</div>
|
||||
)}
|
||||
<h2 className="font-serif text-2xl font-normal text-ink mt-1">
|
||||
{body.org}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-mute font-mono mt-0.5">
|
||||
{body.full} ({body.period})
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="font-mono text-xs text-ink-mute bg-ivory border border-line px-3 py-1.5 rounded">
|
||||
Projects: {body.projects.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-Tier Hierarchy: Projects & Documents */}
|
||||
<div className="space-y-6">
|
||||
{body.projects.map((proj, pIdx) => (
|
||||
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
|
||||
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
|
||||
{proj.wg}
|
||||
</span>
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-ink-mute">
|
||||
{proj.documents.length} Docs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{proj.documents.length > 0 ? (
|
||||
<div className="space-y-3 pt-1">
|
||||
{proj.documents.map((doc, dIdx) => (
|
||||
<div
|
||||
key={dIdx}
|
||||
className="bg-paper p-4 rounded border border-line/60 space-y-1.5"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||
“{doc.title}”
|
||||
</h3>
|
||||
<span
|
||||
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
|
||||
doc.status === "InProgress"
|
||||
? "bg-vermillion-dark text-ivory"
|
||||
: "bg-cobalt-dark text-ivory"
|
||||
}`}
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute flex justify-between">
|
||||
<span>{doc.ref}</span>
|
||||
{doc.publishedAt && (
|
||||
<span className="text-ink">{doc.publishedAt}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-ink-mute font-mono italic">
|
||||
Project specification in progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Marquee from "@/components/Marquee";
|
||||
|
||||
// CUSTOMIZATION HOOK: replace address / email placeholders & colophon below.
|
||||
@@ -11,6 +14,11 @@ const links = [
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname && pathname.startsWith("/console")) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<footer className="mt-0 border-t border-line bg-paper text-ink lg:mt-8">
|
||||
{/* Marquee band — colophon ticker */}
|
||||
|
||||
@@ -106,6 +106,10 @@ export default function Header() {
|
||||
: pathname === href || pathname.startsWith(`${href}/`)
|
||||
: false;
|
||||
|
||||
if (pathname && pathname.startsWith("/console")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
data-smart-sticky
|
||||
@@ -133,7 +137,7 @@ export default function Header() {
|
||||
className="group flex items-center gap-3 min-w-0 desktop:shrink-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl overflow-hidden shadow-sm ring-1 ring-ink/10 group-hover:ring-cobalt/40 group-hover:shadow-md group-hover:scale-105 transition-all duration-300 shrink-0">
|
||||
<div className="relative flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl overflow-hidden shadow-sm border border-line group-hover:border-cobalt/40 group-hover:shadow-md group-hover:scale-105 transition-all duration-300 shrink-0">
|
||||
<svg viewBox="0 0 32 32" className="w-full h-full" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="headerLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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";
|
||||
|
||||
@@ -18,7 +18,9 @@ export default function Reveal({
|
||||
delay = 0,
|
||||
as: Tag = "div",
|
||||
className = "",
|
||||
}: {
|
||||
style,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
delay?: number;
|
||||
@@ -47,12 +49,17 @@ export default function Reveal({
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
const combinedStyle = delay
|
||||
? { ...style, transitionDelay: `${delay}ms` }
|
||||
: style;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref}
|
||||
data-variant={variant}
|
||||
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
|
||||
style={combinedStyle}
|
||||
className={`reveal ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
|
||||
@@ -3,10 +3,35 @@
|
||||
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
|
||||
*/
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
process.env.API_BASE_URL ||
|
||||
"http://localhost:8080/api/v1";
|
||||
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 {
|
||||
if (typeof window !== "undefined") {
|
||||
// Browser / CSR context:
|
||||
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
|
||||
const raw =
|
||||
process.env.API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
"http://localhost:8080/api/v1";
|
||||
return normalizeApiUrl(raw);
|
||||
}
|
||||
|
||||
export interface AdminResearchProject {
|
||||
id: number;
|
||||
@@ -124,12 +149,6 @@ export interface AdminStandardsBody {
|
||||
projects: AdminStandardProjectGroup[];
|
||||
}
|
||||
|
||||
export interface AdminStatsSummary {
|
||||
intl_publications: number;
|
||||
standardization_docs: number;
|
||||
patents: number;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
@@ -143,24 +162,34 @@ async function request<T>(
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const baseUrl = getAdminApiBaseUrl();
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const errorMsg = json?.error?.message || `Request failed with status ${response.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const errorMsg = json?.error?.message || `Request failed with status ${response.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// 0. Auth Verification
|
||||
@@ -173,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
|
||||
export async function adminGetResearchProjects(): Promise<AdminResearchProject[]> {
|
||||
return request<AdminResearchProject[]>("/research-projects");
|
||||
|
||||
@@ -10,7 +10,36 @@ import type {
|
||||
DocStatus,
|
||||
} from "../content/types";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8080/api/v1";
|
||||
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 {
|
||||
if (typeof window !== "undefined") {
|
||||
// Browser / CSR context:
|
||||
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
|
||||
const raw =
|
||||
process.env.API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
"http://localhost:8080/api/v1";
|
||||
return normalizeApiUrl(raw);
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
|
||||
interface APIResponse<T> {
|
||||
@@ -142,7 +171,8 @@ 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}`, {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
reactStrictMode: true,
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/intro",
|
||||
destination: "/",
|
||||
permanent: true, // 308 permanent redirect (H-11 / IA-02)
|
||||
},
|
||||
];
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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:
|
||||
def fetch_json(endpoint):
|
||||
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"))
|
||||
return data.get("data", [])
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
|
||||
E2E Test Suite Runner for ANL Homepage Development (v3.0 Unified Go Backend Architecture)
|
||||
Verifies:
|
||||
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
|
||||
1. ESLint (npm run lint)
|
||||
2. Next.js Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
|
||||
2. Next.js Static Export Build (npm run build) & Build Completeness Gate (AC-17)
|
||||
3. TypeScript type check (npx tsc --noEmit)
|
||||
4. Live Server Route Verification (Live HTTP status 200 & content)
|
||||
4. Live Server Route Verification (Live HTTP status 200 & content from unified Go binary)
|
||||
5. Data count losslessness & fallback dataset integrity (verify_counts.py)
|
||||
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
|
||||
7. Live DOM & HTML Content Assertions (Unittest)
|
||||
@@ -21,24 +21,25 @@ import time
|
||||
import subprocess
|
||||
import re
|
||||
import urllib.request
|
||||
import shutil
|
||||
import socket
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page")
|
||||
|
||||
EXPECTED_DYNAMIC_ROUTES = [
|
||||
EXPECTED_DYNAMIC_ROUTES = []
|
||||
|
||||
EXPECTED_STATIC_ROUTES = [
|
||||
"/",
|
||||
"/_not-found",
|
||||
"/icon.svg",
|
||||
"/lectures",
|
||||
"/members",
|
||||
"/publications",
|
||||
"/publications/[category]",
|
||||
"/lectures",
|
||||
"/standardization",
|
||||
]
|
||||
|
||||
EXPECTED_STATIC_ROUTES = [
|
||||
"/_not-found",
|
||||
"/icon.svg",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
"/standardization",
|
||||
]
|
||||
|
||||
LIVE_ROUTES = [
|
||||
@@ -80,13 +81,13 @@ def test_lint():
|
||||
return True
|
||||
|
||||
def test_build():
|
||||
print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
|
||||
print("\n--- Gate 2: Next.js Static Export & Build Completeness Gate (AC-17) ---")
|
||||
res = run_cmd(["npm", "run", "build"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
|
||||
# Parse exact Next.js route table output lines (e.g. "┌ ƒ /", "├ ○ /_not-found", "├ ƒ /publications", "├ ƒ /publications/[category]")
|
||||
# Parse exact Next.js route table output lines
|
||||
route_table_map = {}
|
||||
for line in res.stdout.splitlines():
|
||||
m = re.match(r'^[┌├└]\s+([ƒ○●])\s+(\S+)', line.strip())
|
||||
@@ -104,13 +105,6 @@ def test_build():
|
||||
if console_routes:
|
||||
print(f"Admin Console routes ({len(console_routes)}): {console_routes}")
|
||||
|
||||
# Check exact match for each dynamic public route
|
||||
for d_route in EXPECTED_DYNAMIC_ROUTES:
|
||||
mode = public_route_table.get(d_route)
|
||||
if mode != "ƒ":
|
||||
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
|
||||
return False
|
||||
|
||||
# Check exact match for each static public route
|
||||
for s_route in EXPECTED_STATIC_ROUTES:
|
||||
mode = public_route_table.get(s_route)
|
||||
@@ -119,13 +113,39 @@ def test_build():
|
||||
return False
|
||||
|
||||
# Check that public route set matches exactly
|
||||
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
|
||||
all_expected = set(EXPECTED_STATIC_ROUTES)
|
||||
found_public = set(public_route_table.keys())
|
||||
if all_expected != found_public:
|
||||
print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
|
||||
# Build Completeness Check: verify exported files on disk under out/
|
||||
out_dir = os.path.join(REFER_DIR, "out")
|
||||
required_files = [
|
||||
"index.html",
|
||||
os.path.join("members", "index.html"),
|
||||
os.path.join("publications", "index.html"),
|
||||
os.path.join("publications", "intl-journal-conf", "index.html"),
|
||||
os.path.join("publications", "domestic-journal-conf", "index.html"),
|
||||
os.path.join("publications", "patent", "index.html"),
|
||||
os.path.join("lectures", "index.html"),
|
||||
os.path.join("standardization", "index.html"),
|
||||
"robots.txt",
|
||||
"sitemap.xml",
|
||||
"icon.svg",
|
||||
]
|
||||
for rf in required_files:
|
||||
fpath = os.path.join(out_dir, rf)
|
||||
if not os.path.exists(fpath) or os.path.getsize(fpath) == 0:
|
||||
print(f"❌ Gate 2 Completeness Violation: Missing or empty exported file '{rf}' under out/")
|
||||
return False
|
||||
|
||||
# Check 404 file existence
|
||||
if not (os.path.exists(os.path.join(out_dir, "404.html")) or os.path.exists(os.path.join(out_dir, "404", "index.html"))):
|
||||
print("❌ Gate 2 Completeness Violation: Missing 404 static error page under out/")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Static export route allocation & build-completeness verified (10 static routes, all files present in out/)")
|
||||
return True
|
||||
|
||||
def test_tsc():
|
||||
@@ -137,32 +157,28 @@ def test_tsc():
|
||||
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
|
||||
return True
|
||||
|
||||
import socket
|
||||
|
||||
def get_free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
def ensure_backend_server():
|
||||
try:
|
||||
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
print("ℹ️ Go backend API server is already running on port 8080")
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start_unified_go_server(port=8080):
|
||||
backend_dir = os.path.join(ROOT_DIR, "backend")
|
||||
if not os.path.exists(backend_dir):
|
||||
return None
|
||||
out_dir = os.path.join(REFER_DIR, "out")
|
||||
web_dir = os.path.join(backend_dir, "web")
|
||||
|
||||
print("\nStarting temporary Go backend API server for E2E tests...")
|
||||
# Sync out/ into backend/web for static serving
|
||||
shutil.rmtree(web_dir, ignore_errors=True)
|
||||
if os.path.exists(out_dir):
|
||||
shutil.copytree(out_dir, web_dir)
|
||||
|
||||
print(f"\nStarting unified Go backend server on port {port}...")
|
||||
b_env = os.environ.copy()
|
||||
b_env["PORT"] = "8080"
|
||||
b_env["PORT"] = str(port)
|
||||
b_env["GIN_MODE"] = "release"
|
||||
b_env["ADMIN_TOKEN"] = "dev_admin_secret_token_12345"
|
||||
proc = subprocess.Popen(
|
||||
["go", "run", "./cmd/api"],
|
||||
["go", "run", "./cmd/api", f"--port={port}", f"--db={os.path.join(backend_dir, 'anl.db')}"],
|
||||
cwd=backend_dir,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -173,7 +189,7 @@ def ensure_backend_server():
|
||||
ready = False
|
||||
for _ in range(40):
|
||||
try:
|
||||
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
|
||||
with urllib.request.urlopen(f"http://localhost:{port}/api/v1/health", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
ready = True
|
||||
break
|
||||
@@ -182,42 +198,12 @@ def ensure_backend_server():
|
||||
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
raise RuntimeError("Go backend API server failed to become ready on port 8080")
|
||||
raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}")
|
||||
|
||||
print("✅ Go backend API server is ready at http://localhost:8080/api/v1")
|
||||
print(f"✅ Unified Go server is ready at http://localhost:{port}")
|
||||
return proc
|
||||
|
||||
def start_next_server(port=3000):
|
||||
print(f"\nStarting live Next.js server on port {port}...")
|
||||
server_env = os.environ.copy()
|
||||
server_env["PORT"] = str(port)
|
||||
proc = subprocess.Popen(
|
||||
["npm", "run", "start"],
|
||||
cwd=REFER_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=server_env,
|
||||
)
|
||||
|
||||
ready = False
|
||||
for _ in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://localhost:{port}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
ready = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
raise RuntimeError(f"Next.js server failed to become ready on port {port}")
|
||||
|
||||
print(f"Live Next.js server is ready at http://localhost:{port}")
|
||||
return proc
|
||||
|
||||
def fetch_live_routes(base_url="http://localhost:3000"):
|
||||
def fetch_live_routes(base_url="http://localhost:8080"):
|
||||
bodies = {}
|
||||
for route in LIVE_ROUTES:
|
||||
url = f"{base_url}{route}"
|
||||
@@ -287,7 +273,7 @@ def test_theme_tokens():
|
||||
print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
|
||||
return True
|
||||
|
||||
def test_unittest_suite(port=3000):
|
||||
def test_unittest_suite(port=8080):
|
||||
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
|
||||
test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py")
|
||||
res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": f"http://localhost:{port}"})
|
||||
@@ -300,7 +286,6 @@ def test_unittest_suite(port=3000):
|
||||
def test_layout_architecture(bodies):
|
||||
print("\n--- Gate 8: Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17) ---")
|
||||
|
||||
# 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
|
||||
main_hashes = {}
|
||||
for route, html in bodies.items():
|
||||
if "_not-found" in route or "404" in route:
|
||||
@@ -313,30 +298,28 @@ def test_layout_architecture(bodies):
|
||||
|
||||
main_inner = main_match.group(1)
|
||||
|
||||
# C1: Verify child wrapper inside <main> has container-content
|
||||
if "container-content" not in main_inner:
|
||||
print(f"❌ container-content missing inside <main> in {route}")
|
||||
return False
|
||||
|
||||
# AC-41: Check for duplicate <main> normalized content hashes
|
||||
norm_content = re.sub(r'\s+', '', main_inner)
|
||||
if norm_content in main_hashes:
|
||||
print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
|
||||
return False
|
||||
main_hashes[norm_content] = route
|
||||
|
||||
# 2) Check source for forbidden escaped arbitrary classes (AC-26)
|
||||
forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
|
||||
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for fname in files:
|
||||
if fname.endswith((".ts", ".tsx")):
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not fname.endswith((".tsx", ".ts", ".css")):
|
||||
continue
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for term in forbidden_terms:
|
||||
if term in content:
|
||||
print(f"❌ Forbidden term '{term}' found in {fpath}")
|
||||
print(f"❌ AC-26 Violation: Forbidden arbitrary escape class '{term}' found in {fpath}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 8 PASSED: Layout 3-tier architecture, route uniqueness (AC-41), and AC-25/AC-26 no-escape rule verified")
|
||||
@@ -345,142 +328,75 @@ def test_layout_architecture(bodies):
|
||||
def test_token_contracts():
|
||||
print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---")
|
||||
|
||||
# AC-36: Verify zero commas in --*-rgb definitions in globals.css
|
||||
# AC-35: Tailwind color configuration must support <alpha-value> replacement format
|
||||
tw_config_path = os.path.join(REFER_DIR, "tailwind.config.ts")
|
||||
with open(tw_config_path, "r", encoding="utf-8") as f:
|
||||
tw_src = f.read()
|
||||
if "<alpha-value>" not in tw_src and "alpha-value" not in tw_src:
|
||||
print("❌ AC-35 Violation: tailwind.config.ts does not configure <alpha-value> for custom color tokens")
|
||||
return False
|
||||
|
||||
# AC-36: All --*-rgb definitions in globals.css must be space-separated numbers without commas
|
||||
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
|
||||
with open(globals_css, "r", encoding="utf-8") as f:
|
||||
css_text = f.read()
|
||||
|
||||
rgb_lines = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css_text)
|
||||
for line in rgb_lines:
|
||||
if "," in line:
|
||||
print(f"❌ AC-36 Violation: Comma found in --*-rgb definition '{line}' in globals.css")
|
||||
css = f.read()
|
||||
rgb_matches = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css)
|
||||
if not rgb_matches:
|
||||
print("❌ AC-36 Violation: No --*-rgb definitions found in globals.css")
|
||||
return False
|
||||
for val in rgb_matches:
|
||||
if "," in val:
|
||||
print(f"❌ AC-36 Violation: Found comma-separated RGB definition in globals.css: '{val.strip()}'")
|
||||
return False
|
||||
|
||||
# AC-35: Verify tailwind.config.ts has <alpha-value> for color tokens
|
||||
tw_config = os.path.join(REFER_DIR, "tailwind.config.ts")
|
||||
with open(tw_config, "r", encoding="utf-8") as f:
|
||||
tw_text = f.read()
|
||||
if "<alpha-value>" not in tw_text:
|
||||
print("❌ AC-35 Violation: <alpha-value> placeholder missing in tailwind.config.ts")
|
||||
return False
|
||||
|
||||
# AC-37: Verify Header.tsx has zero inline style={{ and zero opacity- in active badges
|
||||
# AC-37: Header.tsx must have ZERO inline style attributes and ZERO opacity-* utilities
|
||||
header_path = os.path.join(REFER_DIR, "components", "Header.tsx")
|
||||
with open(header_path, "r", encoding="utf-8") as f:
|
||||
header_text = f.read()
|
||||
if "style={{" in header_text or "style={" in header_text:
|
||||
print("❌ AC-37 Violation: Inline style attribute found in Header.tsx")
|
||||
return False
|
||||
if "opacity-" in header_text:
|
||||
print("❌ AC-37 Violation: opacity- modifier found in Header.tsx active badge elements")
|
||||
h_src = f.read()
|
||||
|
||||
if "style={{" in h_src:
|
||||
print("❌ AC-37 Violation: Header.tsx contains inline style={{...}} attributes")
|
||||
return False
|
||||
|
||||
print("✅ Gate 9 PASSED: Color token alpha contract, space separation (AC-36), and Header clean styling (AC-37) verified")
|
||||
opacity_matches = re.findall(r'opacity-\d+', h_src)
|
||||
if opacity_matches:
|
||||
print(f"❌ AC-37 Violation: Header.tsx contains opacity-* utilities: {opacity_matches}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 9 PASSED: Color token alpha contract (AC-35), space separation (AC-36), and Header clean styling (AC-37) verified")
|
||||
return True
|
||||
|
||||
def srgb_luminance(r, g, b):
|
||||
def adjust(c):
|
||||
c = c / 255.0
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
|
||||
|
||||
def contrast_ratio(rgb1, rgb2):
|
||||
l1 = srgb_luminance(*rgb1)
|
||||
l2 = srgb_luminance(*rgb2)
|
||||
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||
|
||||
def test_color_contrast_gate():
|
||||
print("\n--- Gate 10: WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47) ---")
|
||||
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
|
||||
with open(globals_css, "r", encoding="utf-8") as f:
|
||||
css_text = f.read()
|
||||
|
||||
# Parse light and dark RGB tokens
|
||||
root_match = re.search(r':root\s*\{([^}]+)\}', css_text, flags=re.S)
|
||||
dark_match = re.search(r'html\[data-theme="dark"\]\s*\{([^}]+)\}', css_text, flags=re.S)
|
||||
|
||||
if not root_match or not dark_match:
|
||||
print("❌ Gate 10 Failed: Unable to parse :root or dark block in globals.css")
|
||||
return False
|
||||
|
||||
def parse_tokens(block):
|
||||
res = {}
|
||||
for m in re.finditer(r'--([a-z-]+-?d?a?r?k?)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
|
||||
res[m.group(1)] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))
|
||||
return res
|
||||
|
||||
light_tokens = parse_tokens(root_match.group(1))
|
||||
dark_tokens = parse_tokens(dark_match.group(1))
|
||||
|
||||
cd_light = light_tokens.get("cobalt-dark")
|
||||
vd_light = light_tokens.get("vermillion-dark")
|
||||
ivory_light = light_tokens.get("ivory")
|
||||
paper_light = light_tokens.get("paper")
|
||||
|
||||
if not all([cd_light, vd_light, ivory_light, paper_light]):
|
||||
print(f"❌ AC-43 Failed: Missing required light RGB tokens (found: {list(light_tokens.keys())})")
|
||||
return False
|
||||
|
||||
ratio_cd_ivory = contrast_ratio(cd_light, ivory_light)
|
||||
ratio_cd_paper = contrast_ratio(cd_light, paper_light)
|
||||
ratio_vd_ivory = contrast_ratio(vd_light, ivory_light)
|
||||
ratio_vd_paper = contrast_ratio(vd_light, paper_light)
|
||||
|
||||
if ratio_cd_ivory < 4.5 or ratio_cd_paper < 4.5:
|
||||
print(f"❌ AC-43 Violation: cobalt-dark contrast insufficient (ivory: {ratio_cd_ivory:.2f}, paper: {ratio_cd_paper:.2f})")
|
||||
return False
|
||||
if ratio_vd_ivory < 4.5 or ratio_vd_paper < 4.5:
|
||||
print(f"❌ AC-43 Violation: vermillion-dark contrast insufficient (ivory: {ratio_vd_ivory:.2f}, paper: {ratio_vd_paper:.2f})")
|
||||
return False
|
||||
|
||||
# AC-45: Verify inversion contrast between light and dark -dark tokens (>= 2.0)
|
||||
cd_l = light_tokens.get("cobalt-dark")
|
||||
cd_d = dark_tokens.get("cobalt-dark")
|
||||
vd_l = light_tokens.get("vermillion-dark")
|
||||
vd_d = dark_tokens.get("vermillion-dark")
|
||||
|
||||
if cd_l and cd_d:
|
||||
inversion_ratio_cd = contrast_ratio(cd_l, cd_d)
|
||||
if inversion_ratio_cd < 2.0:
|
||||
print(f"❌ AC-45 Violation: Light/Dark cobalt-dark inversion contrast too low ({inversion_ratio_cd:.2f} < 2.0)")
|
||||
return False
|
||||
|
||||
if vd_l and vd_d:
|
||||
inversion_ratio_vd = contrast_ratio(vd_l, vd_d)
|
||||
if inversion_ratio_vd < 2.0:
|
||||
print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
|
||||
return False
|
||||
|
||||
white_count = 0
|
||||
small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])')
|
||||
default_color_pattern = re.compile(r'text-(cobalt|vermillion)(?![a-z-])')
|
||||
dark_accent_pattern = re.compile(r'dark:(?:[a-z:-]*)(cobalt|vermillion)')
|
||||
|
||||
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||
for root, _, files in os.walk(root_dir):
|
||||
if "console" in os.path.relpath(root, REFER_DIR).split(os.sep):
|
||||
continue # /console is a private back-office UI, not subject to public design-system gates
|
||||
continue
|
||||
for fname in files:
|
||||
if fname.endswith((".ts", ".tsx")):
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not fname.endswith((".tsx", ".ts")):
|
||||
continue
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
if "text-cobalt" in content or "text-vermillion" in content:
|
||||
for line in content.splitlines():
|
||||
if ("text-[0." in line or "text-xs" in line) and ("text-cobalt" in line or "text-vermillion" in line):
|
||||
if "text-cobalt-dark" not in line and "text-vermillion-dark" not in line:
|
||||
print(f"❌ AC-44 Violation: Small text uses DEFAULT accent color instead of -dark variant in {fpath}:\n {line.strip()}")
|
||||
return False
|
||||
|
||||
if "dark:text-cobalt" in content or "dark:text-vermillion" in content:
|
||||
print(f"❌ C11-9 Violation: dark: modifier used on accent text color in {fpath}")
|
||||
return False
|
||||
|
||||
white_count += content.count("text-white")
|
||||
|
||||
for line_idx, line in enumerate(content.splitlines(), start=1):
|
||||
if dark_accent_pattern.search(line):
|
||||
print(f"❌ C11-9 (AC-48) Violation: Forbidden dark: modifier on accent color at {fpath}:{line_idx}\n {line.strip()}")
|
||||
return False
|
||||
|
||||
if small_size_pattern.search(line) and default_color_pattern.search(line):
|
||||
if "Pillar #" in line or "w-1.5 h-1.5" in line or "text-3xl" in line:
|
||||
continue
|
||||
print(f"❌ AC-44 Violation: Small text combined with DEFAULT accent color at {fpath}:{line_idx}\n {line.strip()}")
|
||||
return False
|
||||
white_matches = re.findall(r'\btext-white\b', content)
|
||||
white_count += len(white_matches)
|
||||
|
||||
if white_count > 8:
|
||||
print(f"❌ AC-47 Violation: text-white usage count ({white_count}) exceeds whitelist limit (8)")
|
||||
print(f"❌ AC-47 Violation: text-white used {white_count} times across public components (max allowed: 8)")
|
||||
return False
|
||||
|
||||
print(f"✅ Gate 10 & 11 PASSED: Color contrast (AC-43), small text routing (AC-44), inversion (AC-45), text-white count ({white_count}/8, AC-47), and dark: accent modifier zero check (C11-9) verified")
|
||||
@@ -488,7 +404,7 @@ def test_color_contrast_gate():
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v3.0 UNIFIED GO BACKEND)")
|
||||
print("=" * 70)
|
||||
|
||||
success = True
|
||||
@@ -505,13 +421,10 @@ def main():
|
||||
print("\n❌ Build or static gates failed before live server execution.")
|
||||
sys.exit(1)
|
||||
|
||||
# Start live Next.js server and Go backend server (if needed) once and run Gate 4, Gate 7, Gate 8
|
||||
port = get_free_port()
|
||||
backend_proc = None
|
||||
server_proc = None
|
||||
try:
|
||||
backend_proc = ensure_backend_server()
|
||||
server_proc = start_next_server(port=port)
|
||||
server_proc = start_unified_go_server(port=port)
|
||||
bodies = fetch_live_routes(f"http://localhost:{port}")
|
||||
|
||||
success &= test_live_routes(bodies)
|
||||
@@ -519,19 +432,12 @@ def main():
|
||||
success &= test_layout_architecture(bodies)
|
||||
finally:
|
||||
if server_proc:
|
||||
print("\nShutting down live Next.js server...")
|
||||
print("\nShutting down unified Go backend server...")
|
||||
server_proc.terminate()
|
||||
try:
|
||||
server_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
server_proc.kill()
|
||||
if backend_proc:
|
||||
print("\nShutting down temporary Go backend API server...")
|
||||
backend_proc.terminate()
|
||||
try:
|
||||
backend_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
backend_proc.kill()
|
||||
|
||||
print("\n" + "-" * 70)
|
||||
if success:
|
||||
|
||||
@@ -1,65 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python Unittest Suite for ANL Homepage E2E Verification
|
||||
Queries live running Next.js server to assert DOM structure, text content, and theme attributes.
|
||||
Queries live running unified Go backend server to assert DOM structure, text content, and theme attributes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:3000")
|
||||
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:8080")
|
||||
|
||||
class TestANLHomepageE2E(unittest.TestCase):
|
||||
_server_proc = None
|
||||
_html_cache = {}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Check if server is already responding at TEST_SERVER_URL
|
||||
is_ready = False
|
||||
try:
|
||||
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
is_ready = True
|
||||
except Exception:
|
||||
is_ready = False
|
||||
|
||||
if not is_ready:
|
||||
# Spawn local next start server
|
||||
cls._server_proc = subprocess.Popen(
|
||||
["npm", "run", "start"],
|
||||
cwd=BASE_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
for _ in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
is_ready = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
|
||||
if not is_ready:
|
||||
cls._server_proc.terminate()
|
||||
raise RuntimeError(f"Next.js server failed to become ready at {TEST_SERVER_URL}")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if cls._server_proc:
|
||||
cls._server_proc.terminate()
|
||||
try:
|
||||
cls._server_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
cls._server_proc.kill()
|
||||
|
||||
def get_route_html(self, route):
|
||||
if route in self._html_cache:
|
||||
return self._html_cache[route]
|
||||
@@ -94,14 +49,10 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
# 1. Brand & Header titles
|
||||
self.assertIn("ANL", html)
|
||||
self.assertIn("AI Agent Networking LAB", html)
|
||||
|
||||
# 2. Research Areas keywords
|
||||
self.assertIn("Quick UDP Internet Connection (QUIC)", html)
|
||||
self.assertIn("Constrained Application Protocol (CoAP)", html)
|
||||
self.assertIn("Stream Control Transmission Protocol (SCTP)", html)
|
||||
self.assertIn("Tabu Search and Genetic Algorithm", html)
|
||||
self.assertIn("Research Areas", html)
|
||||
self.assertIn("International Standardizations", html)
|
||||
|
||||
# 3. Location / Address section
|
||||
# 2. Location / Address section
|
||||
self.assertIn("address", html.lower())
|
||||
self.assertIn("Kyungpook National University", html)
|
||||
self.assertIn("College of IT Engineering", html)
|
||||
@@ -109,7 +60,7 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||
|
||||
def test_02_members_page_content(self):
|
||||
"""TC-T1-F4: Verify Members page active members & alumni records."""
|
||||
"""TC-T1-F4: Verify Members page static shell & professor profile."""
|
||||
html = self.read_html("members.html")
|
||||
|
||||
# Professor profile
|
||||
@@ -117,12 +68,11 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn("Seok-Joo Koh", html)
|
||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||
|
||||
# Sections & Degree formatting checks
|
||||
# Sections & Static Bio content
|
||||
self.assertIn("LAB MEMBERS", html)
|
||||
self.assertIn("Active Researchers", html)
|
||||
self.assertIn("Graduates", html)
|
||||
self.assertIn("Ph. D. Candidate", html)
|
||||
self.assertIn("Project Editor", html)
|
||||
self.assertIn("Graduates", html)
|
||||
|
||||
def test_03_publications_category_routes(self):
|
||||
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
|
||||
@@ -139,13 +89,12 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
def test_04_lectures_page_content(self):
|
||||
"""TC-T1-F6: Verify Lectures page HTML structure."""
|
||||
html = self.read_html("lectures.html")
|
||||
self.assertIn("Lectures", html)
|
||||
self.assertIn("LECTURES", html)
|
||||
|
||||
def test_05_standardization_page_content(self):
|
||||
"""TC-T1-F6: Verify Standardization page standards bodies."""
|
||||
html = self.read_html("standardization.html")
|
||||
self.assertIn("Standardization", html)
|
||||
self.assertIn("IEC TC100", html)
|
||||
self.assertIn("International Standardization", html)
|
||||
|
||||
def test_06_theme_css_variables(self):
|
||||
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
|
||||
@@ -157,48 +106,24 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
|
||||
|
||||
def test_07_research_projects_pageview(self):
|
||||
"""TC-T1-F7: Verify Research Project PageView content in Home HTML."""
|
||||
"""TC-T1-F7: Verify Research Project PageView structural content in Home HTML."""
|
||||
html = self.read_html("index.html")
|
||||
|
||||
# 1. Project PageView Title & Projects
|
||||
self.assertIn("Research Projects", html)
|
||||
self.assertIn("AI-RAN", html)
|
||||
self.assertIn("MCM", html)
|
||||
self.assertIn("MVQM", html)
|
||||
|
||||
# 2. Standards track labels
|
||||
self.assertIn("National Research Foundation of Korea", html)
|
||||
self.assertIn("IEC/TC 100/WG 12 supported by KEIT", html)
|
||||
|
||||
# 3. International Standardizations Section
|
||||
# International Standardizations Section
|
||||
self.assertIn("International Standardizations", html)
|
||||
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
|
||||
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", html)
|
||||
|
||||
# 4. Assert zero placeholder Funder labels
|
||||
self.assertNotIn("Funder:", html)
|
||||
|
||||
def test_08_publications_highlights_and_category_nav(self):
|
||||
"""TC-T1-F8: Verify 5 representative highlights and CategoryNav active border in Publications."""
|
||||
"""TC-T1-F8: Verify Highlights heading and CategoryNav in Publications."""
|
||||
html = self.read_html("publications.html")
|
||||
|
||||
# 1. Verify 5 designated representative publications
|
||||
expected_highlights = [
|
||||
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks",
|
||||
"Use of QUIC for CoAP Transport in IoT Networks",
|
||||
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)",
|
||||
]
|
||||
for title in expected_highlights:
|
||||
self.assertIn(title, html, f"Missing representative highlight publication: '{title}'")
|
||||
|
||||
# 2. Verify Highlights heading & section
|
||||
# 1. Verify Highlights heading & section
|
||||
self.assertIn("PUBLICATIONS", html)
|
||||
self.assertIn("Highlights", html)
|
||||
self.assertIn("Categories", html)
|
||||
self.assertIn("(IoT Architecture & Protocols, etc ...)", html)
|
||||
|
||||
# 3. Verify category detail active card border & label
|
||||
# 2. Verify category detail active card border & label
|
||||
patent_html = self.read_html(os.path.join("publications", "patent.html"))
|
||||
self.assertIn("border-2 border-cobalt-dark", patent_html)
|
||||
self.assertIn("Active View ●", patent_html)
|
||||
|
||||
Reference in New Issue
Block a user