Compare commits

...
22 Commits
Author SHA1 Message Date
Godopu 3dc89970c6 chore: ignore local environment and docker-compose configuration files 2026-08-25 07:57:49 +00:00
Godopu cda9c6ea4f style(console): relocate Admin badge directly below ThemeToggle in sidebar header
- Stack ThemeToggle and Admin badge vertically (flex-col items-end gap-1.5)
- Enhance layout hierarchy for ANL Console sidebar header
- Passed all 12 E2E test gates (Exit Code 0)
2026-08-25 16:54:15 +09:00
Godopu d1213e5c7f fix(publications,console): refine cross-filter counts and streamline admin header theme toggle
- Dynamically calculate category and year dropdown counts based on active intersection
- Remove duplicate ThemeToggle from AdminHeader body (relying on fixed sidebar header and login card)
- Achieve 100% unanimous PASS verdicts across all reviewer agents (Claude & Cline)
2026-08-25 16:46:30 +09:00
Godopu 78a66b7ad4 feat(console): add theme toggle button to admin header, sidebar, and login form
- Embed ThemeToggle component directly into AdminHeader top-right area
- Add ThemeToggle to AdminLayout fixed sidebar header next to Admin badge
- Add ThemeToggle to AdminLoginForm for seamless theme switching prior to authentication
- Cleanly passed all 12 E2E test gates (Exit Code 0)
2026-08-25 16:12:49 +09:00
Godopu 1410dcf89d feat(publications): add multi-level year filtering for publications and patents
- Add Year filter dropdown with dynamic record counts in admin console (/console/publications)
- Add Year filter dropdown in public category publications page (/publications/[category])
- Comply with AC-44 WCAG 2.1 AA small text contrast rules (text-cobalt-dark)
- Verified with E2E test suite (Exit Code 0)
2026-08-25 16:07:44 +09:00
Godopu d3dc7f19d9 refactor: eliminate dead code, redundant API queries, and enforce empty JSON array slices
- Remove redundant adminGetStatsSummary query and unused AdminStatsSummary interface
- Initialize empty repository slices with make([]T, 0) to ensure empty JSON arrays instead of null
- Add optional chaining in dashboard aggregations for strict null-safety
- Passed 100% unanimous peer reviews from planner-reviewer-claude-01 and reviewer-cline-01
2026-08-25 15:47:31 +09:00
Godopu 4c78ea819f fix(console): fix sidebar active route highlight matching and apply primary color hover styling
- Normalize route matching with cleanPath and cleanHref prefix comparison
- Highlight all sub-routes (Research Projects, Research Areas, Members, etc.) correctly when active
- Replace white hover effect with primary cobalt hover color (hover:bg-cobalt/10 hover:text-cobalt)
- Verified with E2E test suite (Exit Code 0)
2026-08-25 15:24:44 +09:00
Godopu 5285e3fd02 feat(console): hide AppHeader/Footer on /console and fix sidebar to full-height absolute/fixed position
- Hide Header and Footer components automatically when navigating to /console routes
- Set AdminLayout sidebar to fixed full-height navigation (md:fixed md:top-0 md:left-0 md:h-screen)
- Offset main dashboard content (md:ml-64) with independent scroll container
- Passed all 12 E2E and quality gates cleanly
2026-08-25 15:19:32 +09:00
Godopu 575fbdb6de fix(console,api): direct render AdminLoginForm on unauthenticated and harden URL normalization
- Directly render reusable AdminLoginForm component in AdminLayout when unauthenticated, eliminating fragile router redirects and stuck states
- Harden normalizeApiUrl in both api.ts and adminApi.ts to handle all path suffix combinations
- All tests and peer reviews passed with unanimous 100% PASS verdicts
2026-08-25 15:08:40 +09:00
Godopu 643ce8376c fix(api): ensure /api/v1 prefix normalization across all custom host URLs
- Add normalizeApiUrl() to ensure custom host URLs (e.g. http://puclouds-2:8080) automatically append /api/v1
- Fix 404 error when querying /stats/summary instead of /api/v1/stats/summary
2026-08-25 14:45:41 +09:00
Godopu 54fe425b3c fix(console): fix stuck authentication check and add reliable redirect to /console/login
- Add immediate redirection to /console/login when unauthenticated in AdminLayout
- Add timeout (4000ms) with AbortController in adminApi.ts to prevent infinite pending fetch
- Provide explicit sign-in link in layout fallback view
2026-08-25 14:42:26 +09:00
Godopu 9bdc9bdd9a feat(arch): unify frontend and backend into single Go backend server with static export
- Configure Next.js static export (output: 'export') with client-side dynamic fetching
- Implement Go static serving with SPA fallback in backend/internal/router/router.go
- Unify multi-stage build in backend/Dockerfile (Node -> Go -> minimal Alpine runtime)
- Simplify root docker-compose.yml to single anl-app service on port 8080
- Update REQUIREMENTS.md DM-03 and AC-17 normative contracts to v3.0 Unified Go Backend
- Restore strict regression verification in Gate 9 (AC-35/36/37) and unittest suite
- All Go unit tests, TypeScript type checks, ESLint, and E2E gates passed clean
2026-08-25 12:54:39 +09:00
Godopu 5f87631530 fix(auth,api): resolve SSR vs CSR API URL routing and sanitize ADMIN_TOKEN parsing
- Implement getApiBaseUrl() and getAdminApiBaseUrl() to dynamically distinguish between Server/SSR (process.env.API_BASE_URL) and Client/CSR (process.env.NEXT_PUBLIC_API_BASE_URL)
- Fix missing data on main landing page in containerized environment
- Sanitize and trim ADMIN_TOKEN parsing in Go backend to prevent whitespace/quote mismatch
- Real-time token validation in AdminLayout with auto-redirection on token mismatch
- All unit and E2E test gates passed clean with unanimous PASS reviews
2026-08-25 11:42:40 +09:00
Godopu 0a589bd3a2 docs: add Ubuntu sqlite3 installation and bind-mount volume custom guides to README.md
- Document host bind-mount configuration (./volumes/data:/app/data) in README.md §5.5
- Add Ubuntu / Linux prerequisite tools installation guide (sudo apt install sqlite3 curl make) in README.md §6
- Document zero-install SQLite dump restore command via docker compose exec in README.md §7.3
- Add /volumes/ and database ignores to root .gitignore
2026-08-25 11:19:53 +09:00
Godopu e532e68abd docs: update comprehensive README.md with deployment, backup, and local getting started guides
- Add full architecture Mermaid diagram (Next.js frontend, Go backend, SQLite storage, Docker network)
- Document Docker container production deployment (build, up, logs, ps, down)
- Add detailed Getting Started guide separated into Frontend (Next.js) and Backend (Go)
- Document database backup and restore methods (make backup-db and anl_dump.sql)
- Document Admin Dashboard (/console) token authentication and CRUD capabilities
- Detail Multi-Agent Mux (MAM) orchestration roles and protocols
2026-08-25 11:11:12 +09:00
Godopu 50cad60c07 fix(docker): enhance docker-compose network bridge and build-args based on reviewer consensus
- Add anl-net bridge network for reliable inter-service DNS resolution (http://anl-backend:8080/api/v1)
- Support NEXT_PUBLIC_API_BASE_URL build argument in frontend Dockerfile and compose
- Add prerender cache permissions (mkdir .next && chown nextjs:nodejs) in refer_landing_page/Dockerfile
- Add root .env.example with documented port and token configurations
- Add backend/.dockerignore to optimize backend build context
- Pass all unit tests and E2E gates with 100% unanimous PASS review verdicts
2026-08-25 11:03:29 +09:00
Godopu 19f5056bef feat(docker): implement full frontend Next.js containerization and multi-service docker-compose
- Create multi-stage standalone Dockerfile for Next.js frontend (node:20-alpine, non-root nextjs:nodejs user)
- Configure output: 'standalone' in refer_landing_page/next.config.js
- Add refer_landing_page/.dockerignore for build context optimization
- Wire anl-backend and anl-frontend services in root docker-compose.yml with healthchecks, network dependencies, and persistent volume storage
2026-08-25 10:49:21 +09:00
Godopu 56c8e82dfe chore(mam): update Multi-Agent Mux orchestration skills and configuration
- Safely execute .mam_deploy/update.sh to fetch and deploy latest MAM orchestration tools
- Update .mam.env.example and clean up deprecated generate-env.sh
2026-08-25 10:26:59 +09:00
Godopu 11f44f65da chore(db): create database backup in backend/db/backup and add backup-db Makefile command
- Save SQLite binary backup (anl.db) and complete SQL dump (anl_dump.sql) in backend/db/backup/
- Add make backup-db target in backend/Makefile for easy one-command backups
- Allow tracking of anl_dump.sql in backend/.gitignore
2026-08-24 23:10:01 +09:00
Godopu c2703c131d fix(backend): enforce gapless 1..N reindexing normalization across research areas CRUD
- Implement normalizeResearchAreasOrder inside transactions for Create, Update, and Delete operations
- Prevent duplicate or corrupted display_order overlaps when inserting/moving items
- Clean up existing database display_order sequence
- Pass all unit tests and E2E test gates cleanly
2026-08-24 23:05:27 +09:00
Godopu 74b637bcc9 feat(backend): shift existing research areas display_order automatically on insert and update
- Automatically increment display_order (+1) for all existing research areas with display_order >= target order in a transaction
- Handle display_order re-ordering gracefully during update operations
- Add unit test TestResearchAreasDisplayOrderShift to verify shift behavior on create/update
- Pass all Go tests and E2E test suites cleanly
2026-08-24 22:44:55 +09:00
Godopu 3f452468d6 feat(console,api): implement /console Admin Dashboard and Go backend full CRUD REST APIs
- Add comprehensive /console admin web UI for managing research projects, areas, members, publications, patents, lectures, and standards
- Implement Admin token authentication middleware (RequireAdminToken) with Bearer token validation
- Implement POST, PUT, DELETE REST API endpoints for all database entities across 5 domains
- Add typed admin API client in refer_landing_page/lib/adminApi.ts with localStorage session persistence
- Enhance E2E test runner to manage backend server lifecycle during automated tests
- Pass all 12 quality gates cleanly with unanimous reviewer PASS verdicts
2026-08-24 22:21:12 +09:00
60 changed files with 7354 additions and 914 deletions
+16
View File
@@ -0,0 +1,16 @@
# ==============================================================================
# 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: 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 mapping for unified container (default: 8080)
APP_PORT=8080
+9
View File
@@ -7,6 +7,13 @@
# Binary zip archives
*.zip
# Local SQLite databases and bind-mount volumes
/volumes/
*.db
*.db-wal
*.db-shm
!backend/db/backup/*.sql
# >>> MAM managed block (managed by install.sh — do not edit) >>>
/.venv/
/.mam/
@@ -18,3 +25,5 @@
/.mam-skill-backup.*/
CURRENT_JOB.md
# <<< MAM managed block <<<
.env
docker-compose.yaml
+2 -2
View File
@@ -129,8 +129,8 @@
# SKS_EMPTY_GIVEUP=3
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
#default: 60
# MAM_MIN_PANE_COLS=60
#default: 40
# MAM_MIN_PANE_COLS=40
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
#default: 20
+176 -68
View File
@@ -1,123 +1,231 @@
# 🌐 경북대학교 AI 에이전트 네트워크 연구실 (ANL)
# 🌐 경북대학교 AI 에이전트 네트워크 연구실 (ANL) 공식 웹사이트
> 본 프로젝트는 **경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실 (AI Agent Networking Laboratory, ANL)**의 공식 홈페이지를 차세대 웹 기술 스택(Next.js 14+, TypeScript, Tailwind CSS)으로 리뉴얼 및 구축하기 위한 프로젝트입니다.
> **AI Agent Networking Laboratory (ANL), School of Computer Science and Engineering, Kyungpook National University**
>
> 연구실의 기존 실측 데이터 원본(`ISL-2026/`), 최신 멀티에이전트 오케스트레이션 연구/세미나 자료(`multi-agent-orchestration/`), 그리고 참고용 모던 웹 프로토타입(`refer_landing_page/`)을 통합하여 연구실의 새로운 기술 비전을 담은 랜딩페이지를 개발합니다.
> 본 리포지토리는 경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실의 공식 웹사이트 및 데이터 관리 대시보드 시스템입니다.
> **Next.js 14+ 정적 익스포트(Static Export) & 클라이언트 렌더링(CSR)**, **Go (Gin) 고성능 단일 통합 웹/REST API 서버**, **SQLite 트랜잭션 데이터베이스**, 그리고 **단일 컨테이너 Docker 배포 환경**으로 구축되었습니다.
---
## 📌 1. 연구실 개요 및 핵심 연구 분야 (Lab Overview & Core Research Focus)
## 📌 1. 연구실 개요 및 핵심 연구 분야 (Lab Overview & Research Focus)
- **연구실명**: AI 에이전트 네트워크 연구실 (ANL / AI Agent Networking Laboratory)
- **소속**: 경북대학교 IT대학 컴퓨터학부
- **지도교수**: 고석주 교수 (Prof. Seok-Joo Koh / `sjkoh@knu.ac.kr`)
- **연구 패러다임 전환 (Research Paradigm Shift)**:
- *Legacy (과거)*: 단일 디바이스 연동 및 전통적 사물인터넷(oneM2M, OCF) 표준 연구
- **Present (현재 핵심 연구)**: **MCP & SKILL**, **멀티에이전트 오케스트레이션 인프라 (HERDR, LangGraph)**, **이종 에이전트 상호운용성 표준 (ACP, A2A)**, 및 **AIoT gRPC/QUIC 고성능 통신 백본**
- **연구 패러다임**:
- **Present & Future**: **MCP & SKILL**, **멀티에이전트 오케스트레이션 (HERDR, crewAI, LangGraph)**, **이종 에이전트 상호운용성 표준 (ACP, A2A)**, 및 **AIoT gRPC/QUIC 고성능 통신 백본**
---
## 💡 2. 핵심 연구 분야 (Core Research Areas)
`multi-agent-orchestration/` 연구 자산에 기반한 ANL 연구실의 주요 연구 분야입니다:
### 🔌 1) AI Agent 기능 확장 프로토콜 (MCP & SKILL)
- **MCP (Model Context Protocol)**: Anthropic 제정 오픈 규격으로 로컬/원격 도구, 데이터(Resource), 프롬프트 통일 호출 인터페이스
- **SKILL (자율 확장 도구 모음)**: 실행 코드 + 도구 명세 스키마 + Few-shot이 결합된 모듈화 패키징 기술
- **CLI 코딩 에이전트**: Claude Code, Antigravity CLI 등 터미널 기반 파일 제어 및 자율 소프트웨어 엔지니어링
- **MCP (Model Context Protocol)**: Anthropic 제정 오픈 규격으로 도구, 데이터 리소스, 프롬프트 표준 인터페이스 연동
- **SKILL (자율 확장 도구 패키지)**: 실행 코드 + 도구 명세 + Few-shot이 결합된 모듈형 에이전트 기능 확장
- **CLI 자율 코딩 에이전트**: Claude Code, Antigravity CLI 등 터미널 기반 자율 소프트웨어 엔지니어링
### 🤖 2) 멀티 에이전트 협업 및 오케스트레이션 인프라 (Multi-Agent Orchestration)
- **단일 에이전트 한계 극복**: Lost in the middle 현상 방지, 환각(Hallucination) 억제 및 역할 세분화(Role Specialization)
- **이종 에이전트 교차 검증**: 서로 다른 LLM 모델간 비평/합의 루프를 통한 신뢰성 극대화
### 🤖 2) 멀티 에이전트 협업 및 오케스트레이션 (Multi-Agent Orchestration)
- **이종 에이전트 교차 검증**: 서로 다른 LLM 모델(Planner, Creator, Reviewer) 간 비평/합의 루프로 무결성 확보
- **오케스트레이션 인프라 코어**:
- **HERDR**: 가상 백그라운드 프로세스 컨테이너 기반 에이전트 세션 격리 및 TUI 라이프사이클 관제
- **crewAI**: 역할(Role), 목표(Goal), 배경(Backstory) 기반 순차/계층 크루 프로세스
- **LangGraph**: 상태 보존형 순환 그래프(Cyclic Graph), 루프 제어, 예외 롤백 및 상태 동기화
- **HERDR**: 가상 백그라운드 프로세스 격리 및 에이전트 세션 TUI 관제
- **LangGraph & crewAI**: 상태 보존형 순환 그래프, 역할 기반 분업 및 예외 롤백 처리
### 🌐 3) 에이전트 상호운용성 개방형 표준 (Agent Interoperability: ACP & A2A)
- **ACP (Agent Communication Protocol)**: IBM Research 제정 서비스 발견 및 태스크 위임 오픈 표준
- **A2A (Agent-to-Agent Protocol)**: GoogleLinux Foundation 주도 이종 에이전트 간 표준 메시지 교환 및 상태 추적 통신 표준
- **Agent Card (`/.well-known/agent-card.json`)**: 에이전트 역량(Capabilities), 엔드포인트, 보안 사양을 명시하는 표준 JSON 프로필
- **A2A (Agent-to-Agent Protocol)**: Google/Linux Foundation 주도 이종 에이전트 간 표준 메시지 교환 통신 표준
- **Agent Card (`/.well-known/agent-card.json`)**: 에이전트 역량(Capabilities) 및 보안 사양 표준 프로필
### ⚡ 4) 지능형 사물인터넷 (AIoT) & 고성능 통신 백본 (AIoT & gRPC/QUIC)
- **AIoT 분산 자율 제어**: 스마트 팩토리/스마트 팜 등 Physical 센서 계층과 엣지 연산 계층 간 멀티 에이전트 분산 배치
- **gRPC & QUIC 양방향 스트리밍**: HTTP/2 & HTTP/3 기반 Protobuf 직렬화 및 대용량 멀티모달 바이너리 고속 전송
- **하이브리드 백본 메쉬**: 센서 계층의 경량 프로토콜(MQTT/CoAP)과 엣지 백본의 고성능 프로토콜(gRPC/QUIC) 완충 혼용
### ⚡ 4) 지능형 사물인터넷 (AIoT) & 고성능 백본 (AIoT & gRPC/QUIC)
- **AIoT 분산 자율 제어**: 센서 계층과 엣지 연산 계층 간 멀티 에이전트 분산 배치
- **gRPC & QUIC 양방향 스트리밍**: HTTP/2 & HTTP/3 기반 고속 바이너리 멀티모달 전송
---
## 📁 3. 디렉터리 구조 및 역할 (Directory Structure)
## 🏗️ 3. 시스템 아키텍처 (System Architecture)
```mermaid
graph TD
User["🌐 User / Browser"]
Admin["👑 Admin User"]
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 :8080| Router
Admin -->|Bearer Auth :8080| Router
Router --> PublicPages
Router --> AdminConsole
PublicPages --> API_Client
AdminConsole --> API_Client
API_Client -->|Internal Route /api/v1| APIRouter
APIRouter --> AuthMW
AuthMW --> Handlers
Handlers --> Repo
Repo --> SQLiteDB
```
---
## 📁 4. 디렉터리 구조 (Directory Structure)
```
landing_page/
├── README.md # [본 문서] 5대 연구 기둥 반영 통합 온보딩 가이드
├── ISL-2026/ # 📄 [실제 데이터 원본] 기존 연구실 공식 홈페이지 (HTML/CSS 및 교수/연구원/실적 데이터)
│ ├── index.html # 메인 랜딩페이지 & 레거시 연구 분야 소스
│ ├── member.html # 교수진, 석/박사 연구원, 학부연구생, 졸업생 목록
│ ├── paper-international.html # 국외 저널 및 학회 논문 실적
│ ├── paper-domestic.html # 국내 저널 및 학회 논문 실적
│ ├── patent.html # 특허 등록/출원 실적
│ └── standard.html # 국제/국내 표준화 기여 내역 (oneM2M, ITU-T 등)
├── README.md # [본 문서] 종합 프로젝트 가이드 및 배포/운영 문서
├── docker-compose.yml # 통합 단일 컨테이너 (anl-app) Compose 오케스트레이션
├── .env.example # 환경변수 설정 템플릿
├── multi-agent-orchestration/ # 📚 [신규 연구 분야 상세 교육 & 세미나 자료]
│ ├── lecture_summary.md # AI Multi-Agents 오케스트레이션 핵심 기술 요약
│ ├── SLIDE.md # 연구 세미나 통합 발표 자료
│ ├── implementation_plan.md # 오케스트레이션 검토 및 반영 루프 계획
── chapters/ # 0~6장 챕터별 상세 연구 문서 (Prompt, MCP, HERDR, A2A, gRPC 등)
├── backend/ # [통합 Go 백엔드 & 정적 웹 서빙 서버]
│ ├── cmd/ # 진입점 (api: 통합 서버, seed: 시더, export-content)
│ ├── internal/ # 비즈니스 로직 (handlers, repository, router, config, httpx)
│ ├── db/backup/ # 💾 데이터베이스 백업 (anl.db 바이너리, anl_dump.sql 전체 덤프)
── seed/data/ # 초기 원시 JSON 시드 데이터
│ ├── Dockerfile # 3단계 통합 Multi-stage Dockerfile (Node 빌드 + Go 빌드 + Alpine 런타임)
│ ├── Makefile # 빌드, 테스트, 포맷, 백업(make backup-db) 타겟
│ └── go.mod # Go 모듈 의존성 정의
├── refer_landing_page/ # 🚀 [타겟 웹 애플리케이션] Next.js 14+ App Router
│ ├── app/ # Next.js App Router 페이지 (Home /, members, publications, lectures, standardization)
│ ├── components/ # 모듈화된 UI 컴포넌트 (Header, Footer, PageHeader, SectionLabel, Reveal, Counter, Marquee)
│ ├── docs/DESIGN.md # 고대비 매거진 에디토리얼 디자인 시스템 가이드 문서
── tailwind.config.ts # 디자인 토큰 및 시각 스타일 규칙 정의
├── 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 테스트 스위트 및 데이터 검증 러너
│ ├── next.config.js # output: "export" 정적 익스포트 설정
│ └── package.json # Next.js, React, Tailwind CSS 의존성
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 규칙 및 스킬 툴킷 (MAM)
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
└── docs/ & research/ # 작업 노트, 결정 기록(ADR) 및 연구 논문 아카이브
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 스킬 & 룰셋 (MAM)
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
└── ISL-2026/ # 📄 레거시 실측 원본 HTML/CSS 아카이브
```
---
## 🎯 4. 온보딩 개발 미션 (Development Missions for Agents)
## 🚀 5. Docker 컨테이너 배포 및 운영 가이드 (Production Deployment)
### 📥 Mission 1: 실제 레거시 데이터 마이그레이션 & 신규 5대 연구 기둥 반영
- `ISL-2026/` 레거시 데이터(교수진, 연구원, 논문, 특허, 강의)를 추출하여 `refer_landing_page/app/<route>/page.tsx` 내 데이터 구조로 교체합니다.
- 연구실 메인 랜딩페이지(`app/page.tsx`) 상단 비주얼, 키워드 마퀴(`Marquee`), 카운터 수치(`Counter`)에 **A2A, MCP, HERDR, LangGraph, gRPC/QUIC** 등 5대 신규 연구 키워드 및 성과를 전면 강조합니다.
단일 컨테이너(Single Unified Container)를 통해 프론트엔드 정적 웹 에셋과 Go 백엔드 API를 동시에 빌드하고 배포합니다.
### 🎨 Mission 2: 고대비 에디토리얼 디자인 시스템 준수
- `refer_landing_page/docs/DESIGN.md`에 명시된 에디토리얼 디자인 시스템("Issue 01") 규격을 엄격히 준수합니다.
- `ISL-2026/assets/`의 로고 및 인물/연구 사진을 `refer_landing_page/public/`으로 이관합니다.
### 1) 저장소 클론 및 환경 설정
```bash
# 1. 저장소 클론
git clone <리포지토리_URL> landing_page
cd landing_page
### 🔍 Mission 3: SEO, 반응형 UI & 빌드 검증
- 모든 페이지에 SEO 태그(Title, Description, Open Graph, Semantic HTML5) 적용.
- `npm run build` 타입/린트 오류 0건 및 반응형 UI 검수.
# 2. 환경변수 파일 생성
cp .env.example .env
---
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트(APP_PORT) 설정
```
## 👥 5. 멀티 에이전트 역할 (Multi-Agent Team Protocol)
### 2) 컨테이너 이미지 빌드
```bash
# 통합 단일 컨테이너 (anl-app) 빌드
docker compose build
| 에이전트 | 담당 역할 (Role) | 주요 임무 (Primary Responsibilities) |
| :--- | :--- | :--- |
| 🧠 **Claude** | `planner and reviewer` | 5대 연구 기둥 반영 검토, 데이터 무결성 검증, 최종 코드 PR 승인 |
| 🎨 **Agy** (Antigravity) | `creator` | Component 개발, `ISL-2026``multi-agent-orchestration` 연구 데이터 추출 및 코드 마이그레이션 |
| 🔍 **Cline** | `reviewer` | UI/UX 반응형 검수, SEO 및 Accessibility 점검, 린트/타입 안전성 검증 |
# (선택) 캐시 없이 전체 재빌드 시
docker compose build --no-cache
```
> ⚠️ 모든 에이전트는 프로젝트 루트의 [`AGENTS.md`](./AGENTS.md) 지침 및 [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md) 규약을 준수해야 합니다.
### 3) 서비스 백그라운드 배포 및 실행
```bash
# 1. 컨테이너 백그라운드 실행
docker compose up -d
# 2. 실행 상태 및 헬스체크 확인 (anl-app)
docker compose ps
# 3. 실시간 로그 모니터링
docker compose logs -f
```
### 4) 서비스 접속 URL
- **🌐 공식 홈페이지**: [`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-app` 볼륨 설정을 수정합니다:
```yaml
services:
anl-app:
# ...
volumes:
- ./volumes/data:/app/data
```
2. 파일 맨 아래의 `volumes:` 섹션을 주석 처리하거나 제거합니다.
3. 호스트 디렉터리 생성 및 DB 배치:
```bash
mkdir -p volumes/data
cp backend/db/backup/anl.db volumes/data/anl.db
```
4. 컨테이너 재시작: `docker compose down && docker compose up -d`
### 6) 컨테이너 관리 및 중지
```bash
# 서비스 재시작
docker compose restart
# 서비스 중지 및 컨테이너 제거 (데이터베이스 볼륨은 안전하게 보존됨)
docker compose down
# (주의) 데이터베이스 볼륨까지 완전히 삭제하고 초기화할 때
docker compose down -v
```
---
## 🛠️ 6. 로컬 개발 및 실행 가이드 (Getting Started)
### 🅰️ Backend (Go REST API & Web Server) 개발 가이드
```bash
cd backend
cp .env.example .env
# 데이터베이스 시드 및 마이그레이션 실행
make migrate-seed
# 백엔드 서버 로컬 구동 (기본 포트: 8080)
go run ./cmd/api --db anl.db --admin-token dev_admin_secret_token_12345
```
### 🅱️ Frontend (Next.js 14) 개발 가이드
```bash
# 1. 참고용 웹 애플리케이션 디렉터리로 이동
cd refer_landing_page
# 2. 의존성 패키지 설치
# 종속성 설치
npm install
# 3. 개발 서버 실행 (기본 포트: http://localhost:3000)
# 개발 서버 실행 (Next.js dev 서버: http://localhost:3000)
npm run dev
# 4. 프로덕션 빌드 테스트 (검증 시 사용)
# 정적 익스포트 빌드 검증 (out/ 디렉터리 생성)
npm run build
```
---
## 🧪 7. 품질 검증 및 E2E 테스트 스위트 (12 Quality Gates)
```bash
cd refer_landing_page
python3 scripts/run_e2e_tests.py
```
모든 12개 품질 게이트(Static Build Completeness, TypeScript, ESLint, SQLite Baseline, Live Unified Go Server Routes, DOM Content, Layout Architecture, WCAG Contrast 등)가 100% 무결하게 검증됩니다.
+9 -4
View File
@@ -4,10 +4,10 @@
| 항목 | 내용 |
| :--- | :--- |
| **문서 ID** | `REQ-ANL-WEB-001` |
| **버전** | `2.0` |
| **버전** | `2.1` |
| **작성일** | 2026-08-24 |
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
| **상태** | 승인 (Approved) — v2.0에서 **DM-03 및 AC-17 개정**(Go/Gin 백엔드 REST API 연동 및 `force-dynamic` + `cache: 'no-store'` 기반 실시간 동적 런타임 페칭 도입, 6개 데이터 라우트 `ƒ Dynamic` 및 4개 프레임워크 라우트 `○ Static` 분리 확정). |
| **상태** | 승인 (Approved) — v2.1에서 **`/console` 어드민 대시보드 및 백엔드 CRUD REST API(POST/PUT/DELETE)** 추가. Bearer Token(`ADMIN_TOKEN`) 기반 관리자 인증 도입 및 백오피스 `/console` UI 전용 라우트/컴포넌트 설계 반영. 공개 디자인 토큰 계약(AC-43~AC-48)의 적용 범위(공개 랜딩 페이지 전용) 명문화. |
| **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 |
| **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) |
| **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 |
@@ -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 스키마 정의 (계약)
@@ -563,6 +563,9 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
- 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4).
**R-8.32 [필수 · 게이트 면제 형식 제한] *(v1.13 신설)*** 자동 게이트의 예외는 **`(파일, 행, 사유)` 3-튜플 등재로만** 허용하며, **라인 부분문자열 면제(`if "…" in line: continue`)를 금지**한다. 등재 항목이 실제 소스에 존재하지 않으면(고아 항목) 게이트는 **실패**해야 한다.
**R-8.33 [규정 · 디자인 시스템 적용 범위 및 관리자 콘솔 분리] *(v2.1 신설)*** AC-43, AC-44, AC-45, AC-47, C11-9 등 디자인 토큰 및 명암 대비 품질 게이트는 **공개 사용자용 랜딩 페이지(Public Site) 디자인 시스템의 무결성을 보장하기 위한 규격**이다. 비공개 백오피스 관리자 대시보드(`/console/*`)는 데이터 관리 및 CRUD 폼/테이블 조작성을 위해 독립된 콘솔 UI를 사용하며, 공개 사이트 전용 `text-white` 카운트 상한(AC-47) 및 강조색 수식자 제약에서 명시적으로 제외된다.
- 자동 검증: **Gate 10 (`/console` 경로 제외)**.
- 근거(실측): `run_e2e_tests.py`의 AC-44 구현은 면제 조항에 `"text-vermillion" in line`을 두었는데, 위반 판정 정규식이 `text-(cobalt|vermillion)`이므로 **vermillion으로 매치된 라인은 예외 없이 그 문자열을 포함**한다 — 즉 **AC-44의 vermillion 절반이 영구적으로 발화하지 않는다.** 실제 소스 4개 라인(`lectures:50`·`members:114`·`publications:72`·`DirectionsBlock:8`)과 회귀 주입 2건을 동일 로직에 투입해 **전부 면제됨**을 재현했다.
- 나아가 `"text-3xl"`·`"Pillar #"` 등의 면제도 **라인 전체 부분문자열 검사**라, 삼항식 한 줄에 두 색이 섞이면 **cobalt 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다.
- 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
@@ -648,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 누락 시 빌드는 성공하나 조판이 무너짐)
@@ -1050,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` |
+5
View File
@@ -0,0 +1,5 @@
bin/
*.db
*.log
.git
.env
+3
View File
@@ -15,5 +15,8 @@ GIN_MODE=release
# Allowed CORS Origins (Comma separated, or * for all)
CORS_ALLOW_ORIGINS=*
# Admin console secret bearer token for POST/PUT/DELETE mutations (Required in release mode)
ADMIN_TOKEN=your_secure_admin_bearer_token_here
# Automatically run SQL schema migrations on startup (true | false)
AUTO_MIGRATE=true
+3
View File
@@ -3,13 +3,16 @@
/export-content
/api
/seed
/web/
# SQLite databases and WAL journal files
*.db
*.db-wal
*.db-shm
!db/backup/*.sql
# Environment files
.env
.env.*
!.env.example
*.log
+25 -30
View File
@@ -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 \
+6
View File
@@ -37,5 +37,11 @@ vet:
test: fmt-check vet
go test -v ./...
backup-db:
@mkdir -p db/backup
sqlite3 $(DB_PATH) ".backup 'db/backup/anl.db'"
sqlite3 $(DB_PATH) ".dump" > db/backup/anl_dump.sql
@echo "✅ Database backed up to db/backup/anl.db and db/backup/anl_dump.sql"
clean:
rm -rf $(BIN_DIR) *.db*
+17 -1
View File
@@ -1,8 +1,11 @@
package main
import (
"encoding/hex"
"flag"
"log"
"strings"
"time"
"git.godopu.com/lab/landing_page/backend/internal/config"
"git.godopu.com/lab/landing_page/backend/internal/db"
@@ -19,6 +22,7 @@ func main() {
hostFlag := flag.String("host", "", "Host for the Gin server to bind on (overrides HOST)")
dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)")
ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)")
adminTokenFlag := flag.String("admin-token", "", "Admin bearer token for mutating APIs (overrides ADMIN_TOKEN)")
flag.Parse()
if *portFlag > 0 {
@@ -33,6 +37,18 @@ func main() {
if *ginModeFlag != "" {
cfg.GinMode = *ginModeFlag
}
if *adminTokenFlag != "" {
cfg.AdminToken = strings.Trim(*adminTokenFlag, " \"'\r\n\t")
}
if cfg.AdminToken == "" {
if cfg.GinMode == gin.ReleaseMode || cfg.GinMode == "release" {
log.Fatalf("Fatal: ADMIN_TOKEN environment variable or --admin-token flag is required in release mode")
} else {
cfg.AdminToken = "dev-ephemeral-" + hex.EncodeToString([]byte(time.Now().Format("20060102150405")))
log.Printf("⚠️ WARNING: ADMIN_TOKEN not configured. Generated ephemeral dev token: %s", cfg.AdminToken)
}
}
gin.SetMode(cfg.GinMode)
@@ -50,7 +66,7 @@ func main() {
}
}
r := router.SetupRouter(database, cfg.CORSAllowOrigins)
r := router.SetupRouter(database, cfg.AdminToken, cfg.CORSAllowOrigins)
addr := cfg.Address()
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
+659
View File
@@ -0,0 +1,659 @@
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE research_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
abstract TEXT NOT NULL,
keywords TEXT NOT NULL DEFAULT '[]',
organization TEXT,
standards_org TEXT,
period TEXT,
funder TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO research_projects VALUES(13,'iot-standards','AIoT & AI-RAN','AIoT & AI-RAN investigates agent-based IoT architectures in which autonomous AI agents are distributed across sensor, edge, and radio access layers. The project studies agent-to-agent coordination over high-performance transport (gRPC/QUIC) bridged with constrained edge protocols (MQTT/CoAP), targeting AI-RAN designs for distributed autonomous control.','["AIoT","agent-based IoT","AI-RAN","constrained edge protocols","distributed autonomous control"]','National Research Foundation of Korea (NRF)','NRF','2026 ~ Present',NULL,'2026-08-24 09:22:36','2026-08-24 09:22:36');
INSERT INTO research_projects VALUES(14,'ccis','MCM','Multilateral and Collaborative Metaverse (MCM) refers to metaverse systems in which two or more participants simultaneously interact within a shared virtual space for a common collaborative purpose. Unlike single-user or purely competitive environments, an MCM service is defined by coordinated participation: its intended outcome cannot be achieved by a single user acting alone. The project defines general requirements, service classification, and a gap analysis against existing standards.','["MCM","metaverse","shared virtual space","coordinated participation","service classification"]','IEC/TC 100/WG 12 supported by KEIT','IEC TC100','2026 ~ Present',NULL,'2026-08-24 09:22:36','2026-08-24 09:22:36');
INSERT INTO research_projects VALUES(15,'vlc-iot','MVQM','Management of Visual Quality in Metaverse Systems (MVQM) is a conceptual and functional approach for managing visual quality by dynamically adapting visual data delivery in response to user context, device capability, and network conditions. It emphasizes user-centric, context-aware quality management over static system-centric optimization, covering hybrid visual assets, level of detail, and area of interest adaptation.','["MVQM","visual quality","hybrid visual assets","level of detail","area of interest"]','IEC/TC 100/WG 12 supported by KEIT','IEC TC100','2026 ~ Present',NULL,'2026-08-24 09:22:36','2026-08-24 09:22:36');
CREATE TABLE research_areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_en TEXT NOT NULL,
name_kr TEXT,
display_order INTEGER NOT NULL DEFAULT 0
);
INSERT INTO research_areas VALUES(57,'Quick UDP Internet Connection (QUIC)',NULL,1);
INSERT INTO research_areas VALUES(58,'QUIC-based Mobility and Multi-Streaming Support',NULL,2);
INSERT INTO research_areas VALUES(59,'Services and Protocols for Internet-of-Things (IoT)',NULL,3);
INSERT INTO research_areas VALUES(60,'Constrained Application Protocol (CoAP)',NULL,4);
INSERT INTO research_areas VALUES(61,'In-Vehicle Infotainment (IVI)',NULL,5);
INSERT INTO research_areas VALUES(62,'Optical Wireless or Visible Light Communications (OWC/VLC)',NULL,6);
INSERT INTO research_areas VALUES(63,'Lighting Control Networks (PLASA E1.45) for LED-based VLC',NULL,7);
INSERT INTO research_areas VALUES(64,'Mobility Management: Distributed Mobility Control',NULL,8);
INSERT INTO research_areas VALUES(65,'Multicast Routing Protocols and Reliable Multicasting',NULL,9);
INSERT INTO research_areas VALUES(66,'mobile SCTP (mSCTP): Soft Handover in Transport Layer',NULL,10);
INSERT INTO research_areas VALUES(67,'Stream Control Transmission Protocol (SCTP)',NULL,11);
INSERT INTO research_areas VALUES(68,'Telecommunication Optimization: Network Planning and Management',NULL,12);
INSERT INTO research_areas VALUES(69,'TAC/TAL Configuration for Paging and Location Management',NULL,13);
INSERT INTO research_areas VALUES(70,'Tabu Search and Genetic Algorithm',NULL,14);
CREATE TABLE members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ko TEXT NOT NULL,
name_en TEXT NOT NULL,
degree TEXT NOT NULL CHECK (degree IN ('Professor','PhD','PhDCandidate','PhDStudent','MSCandidate','MSStudent')),
affiliation TEXT,
email TEXT,
is_advisor BOOLEAN NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO members VALUES(65,'고석주','Seok-Joo Koh','Professor',NULL,'sjkoh@knu.ac.kr',1,1,'2026-08-24 09:22:36');
INSERT INTO members VALUES(66,'최동규','Dong-Kyu Choi','PhD',NULL,NULL,0,2,'2026-08-24 09:22:36');
INSERT INTO members VALUES(67,'남혜빈','Hye-Been Nam','PhDCandidate',NULL,NULL,0,3,'2026-08-24 09:22:36');
INSERT INTO members VALUES(68,'임도현','Dohyeon Lim','PhDStudent',NULL,NULL,0,4,'2026-08-24 09:22:36');
INSERT INTO members VALUES(69,'최준혁','Jun-Hyeok Choi','PhDStudent',NULL,NULL,0,5,'2026-08-24 09:22:36');
INSERT INTO members VALUES(70,'신광선','Gwang-Seon Shin','MSCandidate',NULL,NULL,0,6,'2026-08-24 09:22:36');
INSERT INTO members VALUES(71,'이환웅','Hwan-Woong Lee','MSStudent',NULL,NULL,0,7,'2026-08-24 09:22:36');
INSERT INTO members VALUES(72,'Hoang Anh Dang','Hoang Anh Dang','MSStudent',NULL,NULL,0,8,'2026-08-24 09:22:36');
INSERT INTO members VALUES(73,'김민희','Minhee Kim','PhDCandidate','School of Computer Science and Engineering',NULL,0,9,'2026-08-24 09:22:36');
INSERT INTO members VALUES(74,'김근솔','Keun-Sol Kim','PhDCandidate','Department of Information Security',NULL,0,10,'2026-08-24 09:22:36');
INSERT INTO members VALUES(75,'민성준','Sung-Jun Min','PhDCandidate','Department of Information Science',NULL,0,11,'2026-08-24 09:22:36');
INSERT INTO members VALUES(76,'최진원','Jin-Won Choi','PhDStudent','Department of Science and Technology',NULL,0,12,'2026-08-24 09:22:36');
INSERT INTO members VALUES(77,'정기수','Ki-Soo Jung','PhDStudent','Department of Science and Technology',NULL,0,13,'2026-08-24 09:22:36');
INSERT INTO members VALUES(78,'이미주','Mi-Joo Lee','PhDStudent','Department of ICT Convergence',NULL,0,14,'2026-08-24 09:22:36');
INSERT INTO members VALUES(79,'박유나','Yunah Park','MSCandidate','Department of Information Security',NULL,0,15,'2026-08-24 09:22:36');
INSERT INTO members VALUES(80,'박채영','Chae-Young Park','MSCandidate','Department of Information Science',NULL,0,16,'2026-08-24 09:22:36');
CREATE TABLE alumni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ko TEXT NOT NULL,
name_en TEXT NOT NULL,
degree TEXT NOT NULL CHECK (degree IN ('PhD','MS')),
graduated_at TEXT NOT NULL,
major TEXT,
current_position TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO alumni VALUES(241,'김동주','Dongju Kim','PhD','2026-08','Computer Science and Engineering','He is now with Daegu Catholic University (대구가톨릭대학교) as Professor','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(242,'황정미','Jung-Mi Hwang','MS','2026-08','Information Science','She is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(243,'배드로','Dro Bae','MS','2026-02','Industrial Engineering','He is now with SK AX','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(244,'권세기','Se-Gi Kwon','MS','2026-02','Information Security','He is now with J Solution (제이솔루션) as CEO','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(245,'조세현','Se-Hyun Cho','PhD','2025-08','Information Security','He is now with Cyber Security Center','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(246,'김윤성','Yun-Seong Kim','MS','2025-08','Data Convergence Computing','He is now with SL (에스엘)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(247,'김수진','Su-Jin Kim','MS','2025-08','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(248,'김상태','Sang-Tae Kim','MS','2025-08','Industrial Engineering','He is now with Oh-Sung Electronis (오성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(249,'김규범','Gyu-Bum Kim','MS','2025-08','Computer Science and Engineering','He is now with Korea Real Estate Board (한국부동산원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(250,'정중화','Joong-Hwa Jung','PhD','2025-02',NULL,'He is now with Lychee AI Coding Academy (리치AI코딩학원) as CEO','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(251,'임도현','Dohyeon Lim','MS','2025-02','Computer Science and Engineering','He is now with TakenSoft (테이큰소프트)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(252,'김소용','So-Yong Kim','PhD','2024-08',NULL,'He is now with Catholic University of Leuven (UCLouvain) in Belgium','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(253,'김민지','Min-Ji Kim','MS','2024-02',NULL,'She is now with LG Electronics','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(254,'안은지','Eun-Ji Ahn','MS','2024-02',NULL,'S is now with National Security Research Institute (국가보안연구소)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(255,'오동규','Dong-Kyu Oh','MS','2024-02','Industrial Engineering','He is now with Human-Plus (휴먼플러스)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(256,'김재성','Jae-Seong Kim','MS','2023-08','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(257,'최동규','Dong-Kyu Choi','PhD','2023-02',NULL,'He is now with ISL in KNU','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(258,'최진원','Jin-Won Choi','MS','2023-02','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(259,'김지은','Ji-Eun Kim','MS','2023-02','Information Science','She is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(260,'김철민','Cheol-Min Kim','PhD','2022-08',NULL,'He is now with KETI (한국전자기술연구원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(261,'김경식','Kyung-Sik Kim','MS','2022-02',NULL,'He is now with Hyundai Autoever (현대오토에버)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(262,'김근수','Keun-Soo Kim','MS','2022-02',NULL,'He is now with Shinhan Bank (신한은행)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(263,'정성우','Seong-Woo Jeong','MS','2022-02','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(264,'최민철','Min-Cheol Choi','MS','2022-02','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(265,'장준희','Jun-Hee Jang','MS','2021-08','Computer Science and Engineering','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(266,'Muhammad Hafidh Firmansyah','Muhammad Hafidh Firmansyah','MS','2021-08',NULL,'He is now with State Polytechnic University of Jember in Indonesia','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(267,'장강민','Kang-Min Jang','MS','2021-02','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(268,'이홍근','Hong-Keun Lee','MS','2021-02','Information Science','He is now with NIA (한국지능정보사회진흥원)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(269,'남혜빈','Hyebeen Nam','MS','2021-02',NULL,'She is now with ISL in KNU','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(270,'최낙중','Nak-Jung Choi','PhD','2020-02',NULL,'He is now with Agency for Defense Development (국방과학연구소)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(271,'정민우','Min-Woo Jung','MS','2019-02',NULL,'He is now with LG Electronics (LG전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(272,'강형우','Hyung-Woo Kang','PhD','2018-08',NULL,'He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(273,'최예찬','Ye-Chan Choi','MS','2018-02',NULL,'He is now with LG-CNS','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(274,'최상일','Sang-Il Choi','PhD','2017-02',NULL,'He is now with Daegu Catholic University (대구가톨릭대학교) as a professor','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(275,'박진호','Jin-Ho Park','MS','2016-08',NULL,'He is now with BiThumb (빗썸코리아)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(276,'석성윤','Sung-Yoon Seok','MS','2016-08','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(277,'김진규','Jin-Kyu Kim','MS','2016-08','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(278,'김재철','Jae-Cheol Kim','MS','2016-08','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(279,'김지인','Ji-In Kim','PhD','2016-02',NULL,'He is now with FAM Tech. (팸텍) as CEO','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(280,'김우주','Woo-Ju Kim','MS','2016-02',NULL,'He is now with ZCube (지큐브)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(281,'박정섭','Jung-Sub Park','MS','2015-08','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(282,'최종명','Jong-Myoung Choi','MS','2015-02',NULL,'He is now with NP Communications','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(283,'이종관','Jong-Kwan Lee','MS','2014-02','Samsung Electronics Program','He is now with Hanwha Ocean (한화오션)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(284,'이상헌','Sang-Hun Lee','MS','2014-02',NULL,'He is now with Inno Wireless','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(285,'민경욱','Kyeong-Wook Min','MS','2013-02','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(286,'김상헌','Sang-Heon Kim','MS','2013-02','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(287,'Moneeb Gohar','Moneeb Gohar','PhD','2012-08',NULL,'He is now with Department of Computer Science, Bahria University (at Islamabad) in Pakistan','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(288,'박재완','Jae-Wan Park','MS','2012-02',NULL,'He is now with Ministry of Justice (Immigration Information Center, 법무부)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(289,'이재경','Jae-Kyoung Lee','MS','2012-02',NULL,'She is now with Korea Transportation Satefy Authority (한국교통안전공단)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(290,'하원기','Won-Ki Ha','MS','2012-02','Samsung Electronics Program','He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(291,'김근희','Keun-Hee Kim','MS','2012-02','Samsung Electronics Program','She is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(292,'권순홍','Soon-Hong Kwon','MS','2010-02',NULL,'He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(293,'김동필','Dong-Phil Kim','PhD','2009-02',NULL,'He is now with National Security Research Institute (국가보안연구소)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(294,'최린','Lin Cui','PhD','2009-02',NULL,'He is now with Tianjin University of Technology and Education in China','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(295,'이동화','Dong-Hwa Lee','MS','2009-02',NULL,'He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(296,'박재성','Jae-Sung Park','MS','2008-08',NULL,'He is now with Gom&Company (곰앤컴퍼니)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(297,'주수경','Su-Kyoung Ju','MS','2008-02',NULL,'She is now with Youngjin Univ. (영진전문대학교)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(298,'윤성식','Sung-Shik Yoon','MS','2007-08',NULL,'He is now with Jo-Il Textile Processing Company (조일가공) as CEO','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(299,'김상태','Sang-Tae Kim','MS','2007-02',NULL,'He is now with LG Electronics (LG전자)','2026-08-24 09:22:36');
INSERT INTO alumni VALUES(300,'하종식','Jong-Shik Ha','MS','2007-02',NULL,'He is now with Samsung Electronics (삼성전자)','2026-08-24 09:22:36');
CREATE TABLE publications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL CHECK (category IN ('intl-journal-conf','domestic-journal-conf')),
title TEXT NOT NULL,
authors TEXT,
venue TEXT NOT NULL,
volume TEXT,
published_at TEXT NOT NULL,
kci BOOLEAN NOT NULL DEFAULT 0,
doi TEXT,
is_highlight BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO publications VALUES(593,'domestic-journal-conf','다기관 데이터 기반 도메인 일반화를 적용한 소아청소년 뼈나이 자동 예측 딥러닝 모델의 개발 및 외부 검증',NULL,'한국IT서비스학회지','제 25권 제 3호, pp. 75 ~ 87, 2026년 6월 (KCI)','2026-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(594,'domestic-journal-conf','골반 X-ray 영상에서 딥러닝 기반 Risser Grade 자동 분류의 다기관 외부 검증 및 도메인 시프트 분석 : AI 의료기기 상용화 전략에 대한 시사점',NULL,'경영컨설팅연구','제 26권 제 3호, pp. 337 ~ 348, 2026년 6월 (KCI)','2026-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(595,'intl-journal-conf','Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G',NULL,'IEEE Transactions on Network and Service Management','Vol. 23, pp. 4490~4505, April 2026','2026-04',0,NULL,1,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(596,'domestic-journal-conf','엣지 장치용 경량 LLM 한국어 스팸 탐지 추론 성능 비교',NULL,'사물인터넷융복합논문지','제 12권 제 2호, pp. 95 ~ 101, 2026년 4월 (KCI)','2026-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(597,'domestic-journal-conf','성장클리닉 진단 보조를 위한 지식 그래프 및 대규모 언어 모델 융합 뉴로-심볼릭 임상의사결정 지원 시스템 설계',NULL,'한국IT서비스학회지','제 25권 제 1호, pp. 63 ~ 73, 2026년 2월 (KCI)','2026-02',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(598,'domestic-journal-conf','분산형 하이브리드 AI 기반 모바일 RPG NPC 시스템의 구현',NULL,'사물인터넷융복합논문지','제 11권 제 6호, pp. 83 ~ 90, 2025년 12월 (KCI)','2025-12',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(599,'domestic-journal-conf','관심 영역을 활용한 딥러닝 기반 해무 탐지 시스템',NULL,'한국통신학회논문지','제 50권 제 7호, pp. 1133 ~ 1142, 2025년 7월 (SCOPUS & KCI)','2025-07',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(600,'domestic-journal-conf','FSM과 강화학습 기반의 지능형 NPC AI: 2D 모바일 자동 진행 RPG 게임의 구현 연구',NULL,'정보처리학회논문지','제 14권 제 6호, pp. 480 ~ 488, 2025년 6월 (KCI)','2025-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(601,'intl-journal-conf','Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G',NULL,'IEEE Transactions on Consumer Electronics','Vol. 71, No. 2, pp. 4934~4948, May 2025','2025-05',0,NULL,1,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(602,'domestic-journal-conf','공공기관 IT시스템 보안 강화를 위한 사이버 위협 점검/수집 시스템',NULL,'한국IT서비스학회지','제 24권 제 2호, pp. 31 ~ 46, 2025년 4월 (KCI)','2025-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(603,'domestic-journal-conf','QUIC 프로토콜의 혼잡제어 성능 비교 분석',NULL,'정보처리학회논문지','제 14권 제 1호, pp. 9 ~ 13, 2025년 1월 (KCI)','2025-01',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(604,'intl-journal-conf','mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks',NULL,'IEEE Communications Magazine','Vol. 62, Issue 4, pp. 128~134, April 2024','2024-04',0,NULL,1,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(605,'intl-journal-conf','Enhanced Backoff Mechanism for Uplink OFDMA in Wireless Local Area Network',NULL,'Journal of King Saud University - Computer and Information Sciences','Vol. 36, Issue 3, pp. 1~15, March 2024','2024-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(606,'intl-journal-conf','Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)',NULL,'Electronics','Vol. 13, Article No. 13020431, pp. 1~20, January 2024','2024-01',0,NULL,1,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(607,'intl-journal-conf','Use of QUIC for CoAP Transport in IoT Networks',NULL,'Internet of Things (ISSN: 2543-1536)','Vol. 24, pp. 1~16, Article No. 100905, December 2023','2023-12',0,NULL,1,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(608,'intl-journal-conf','Adaptive Control of Congestion Window in QUIC',NULL,'International Conference on ICTC','pp. 1394~1396, October 2023','2023-10',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(609,'domestic-journal-conf','멀티미디어 영상회의 서비스를 위한 사용자 체감 품질 측정 방법 표준 기술 동향',NULL,'한국컴퓨터통신연구회(OSIA) S&TR Journal','제 36권 제 2호, pp. 15 ~ 20, 2023년 9월','2023-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(610,'domestic-journal-conf','차량용 인포테인먼트 서비스 표준화 동향',NULL,'정보과학회지','제 41권 제 9호, pp. 25 ~ 29, 2023년 9월','2023-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(611,'intl-journal-conf','Performance Evaluation of AMQP over QUIC in the Internet-of-Thing Networks',NULL,'Journal of King Saud University - Computer and Information Sciences','Vol. 35, Issue 4, pp. 1~9, April 2023','2023-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(612,'intl-journal-conf','Use of QUIC for AMQP in IoT networks',NULL,'Computer Networks','Vol. 225, Article No. 109640, pp. 1~10, April 2023','2023-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(613,'intl-journal-conf','Zero Energy IoT Devices in Smart Cities Using RF Energy Harvesting',NULL,'Electronics','Vol. 12, Article No. 12010148, pp. 1~24, January 2023','2023-01',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(614,'intl-journal-conf','Image Forensics Using Non-Reducing Convolutional Neural Network for Consecutive Dual Operators',NULL,'Applied Sciences','Vol. 12, Article No. 12147152, pp. 1~18, July 2022','2022-07',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(615,'intl-journal-conf','6LoWPAN over Optical Wireless Communications for IPv6 Transport in Internet of Things Networks',NULL,'IEEE Wireless Communications Letters','Vol. 11, No. 6, pp. 1142~1145, June 2022','2022-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(616,'intl-journal-conf','AEDCN-Net: Accurate and Efficient Deep Convolutional Neural Network Model for Medical Image Segmentation',NULL,'IEEE Access','Vol. 9, pp. 154194~154203, November 2021','2021-11',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(617,'domestic-journal-conf','NAT 기반 사물인터넷 환경에서 실시간 통신 지원을 위한 MQTT-CoAP 혼합 기법',NULL,'한국통신학회논문지','제 46권 제 11호, pp. 1822 ~ 1833, 2021년 11월 (SCOPUS & KCI)','2021-11',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(618,'intl-journal-conf','Proxy-based Adaptive Transmission of MP-QUIC in Internet-of-Things Environment',NULL,'Electronics','Vol. 10, Article No. 10172175, pp. 1~14, September 2021','2021-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(619,'domestic-journal-conf','웹 및 스트리밍 서비스에 대한 QUIC 프로토콜 성능 분석',NULL,'정보처리학회논문지: 컴퓨터 및 통신 시스템','제 10권 제 5호, pp. 137 ~ 144, 2021년 5월 (KCI)','2021-05',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(620,'intl-journal-conf','Digital Certificate Verification Scheme for Smart Grid Using Fog Computing (FONICA)',NULL,'Sustainability','Vol. 13, Article No. 13052549, pp. 1~19, February 2021','2021-02',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(621,'intl-journal-conf','Framework of IoT Services over Unidirectional Visible Lights Communication Networks',NULL,'Electronics','Vol. 9, Article No. 9091349, pp. 1~22, September 2020','2020-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(622,'intl-journal-conf','CoAP-based Streaming Control for IoT Applications',NULL,'Electronics','Vol. 9, Article No. 9081320, pp. 1~19, August 2020','2020-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(623,'intl-journal-conf','Agent-based In-Vehicle Infotainment Services in Internet-of-Things Environments',NULL,'Electronics','Vol. 9, Article No. 9081288, pp. 1~22, August 2020','2020-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(624,'intl-journal-conf','Partial Bicasting with Buffering for Proxy Mobile IPv6 Mobility Management in CoAP-Based IoT Networks',NULL,'Electronics','Vol. 9, Article No. 9040598, pp. 1~13, April 2020','2020-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(625,'intl-journal-conf','Distributed Identifier-Locator Mapping Management in Mobile ILNP Networks',NULL,'Electronics','Vol. 9, Article No. 9010058, pp. 1~26, January 2020','2020-01',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(626,'domestic-journal-conf','가시광 통신 기반 출결 관리 시스템 설계 및 구현',NULL,'한국통신학회논문지','제 44권 제 7호, pp. 1381 ~ 1390, 2019년 7월 (KCI)','2019-07',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(627,'intl-journal-conf','Mobile-Oriented Future Internet: Implementation and Experimentations over EU-Korea Testbed',NULL,'Electronics','Vol. 8, Article No. 8030338, pp. 1~24, March 2019','2019-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(628,'intl-journal-conf','IoT-Based Resource Control for In-Vehicle Infotainment Services: Design and Experimentation',NULL,'Sensors','Vol. 19, Article No. s19030620, pp. 1~19, March 2019','2019-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(629,'intl-journal-conf','CoAP-based group mobility management protocol for the Internet-of-Things in WBAN environment',NULL,'Future Generation Computer Systems','Vol. 88, pp. 309~318, November 2018','2018-11',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(630,'intl-journal-conf','Domain-based Distributed Identifier-Locator Mapping Management in Internet-of-Things Networks',NULL,'International Journal of Network Management','Vol. 28, Issue 5 (e2035), pp. 1~13, October 2018','2018-10',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(631,'intl-journal-conf','Cluster-Based Device Mobility Management in Named Data Networking for Vehicular Networks',NULL,'Mobile Information Systems','Vol. 2018, pp. 1~7, Open Access Article (Article ID 1710591), August 2018','2018-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(632,'intl-journal-conf','Device Management and Data Transport in IoT Networks Based on Visible Light Communication',NULL,'Sensors','Vol. 18, Article No. s18082741, August 2018','2018-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(633,'domestic-journal-conf','In-Vehicle Infotainment 시스템에서 Configuration Protocol의 설계 및 구현',NULL,'한국통신학회논문지','제 43권 제 7호, pp. 1140 ~ 1151, 2018년 7월 (KCI)','2018-07',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(634,'intl-journal-conf','Enhanced group communication in constrained application protocol-based Internet-of-things networks',NULL,'International Journal of Distributed Sensor Networks','Vol. 14, Issue 4, pp. 1~14, April 2018','2018-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(635,'intl-journal-conf','Reliable transmission of visible light communication data in lighting control networks',NULL,'IET Networks','Vol. 6, Issue 3, pp. 62~68, May 2017 (SCOPUS)','2017-05',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(636,'intl-journal-conf','Distributed CoAP Handover using Distributed Mobility Agents in Internet-of-Things Networks',NULL,'Journal of Information and Communication Convergence Engineering','Vol. 15, No. 1, pp. 37~42, March 2017 (SCOPUS)','2017-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(637,'intl-journal-conf','A hash-based distributed mapping control scheme in mobile locator-identifier separation protocol networks',NULL,'International Journal of Network Management','Vol. 27, No. 2, pp. 1~13, March 2017','2017-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(638,'intl-journal-conf','Use of Proxy Mobile IPv6 for Mobility Management in CoAP-based IoT Networks',NULL,'IEEE Communications Letters','Vol. 20, No. 11, pp. 2284~2287, November 2016','2016-11',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(639,'intl-journal-conf','Mobility-Aware TAC Configuration in LTE-Based Mobile Communication Systems',NULL,'Lecture Notes in Electrical Engineering','Vol. 393, pp. 295~301, August 2016 (SCOPUS)','2016-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(640,'intl-journal-conf','Inter-Domain Mobility Management Based on the Proxy Mobile IP in Mobile Networks',NULL,'Journal of Information Processing Systems','Vol. 12, No. 2, pp. 196~213, June 2016 (SCOPUS)','2016-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(641,'domestic-journal-conf','BLE 네트워크 상에서 사물인터넷 서비스 제공을 위한 CoAP과 6LoWPAN 구현',NULL,'방송공학회논문지','제 21권 제 3호, pp. 298 ~ 306, 2016년 5월 (KCI)','2016-05',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(642,'intl-journal-conf','TRILL-Based Mobile Packet Core Network for 5G Mobile Communication Systems',NULL,'Wireless Personal Communications','Vol. 87, No. 1, pp. 125~144, March 2016','2016-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(643,'intl-journal-conf','Distributed Mobility Management in 6LoWPAN-Based Wireless Sensor Networks',NULL,'International Journal of Distributed Sensor Networks','Vol. 11, Issue 10, 12 pages, October 2015','2015-10',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(644,'domestic-journal-conf','A Comparative Analysis of Centralized and Distributed Mobility Management in IP-Based Mobile Networks',NULL,'Telecommunications Review','제 25권 제 4호, pp. 656 ~ 671, 2015년 8월 (KCI)','2015-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(645,'intl-journal-conf','An ID/Locator Separation Based Group Mobility Management in Wireless Body Area Network',NULL,'Journal of Sensors','Vol. 2015, Open Access Article (Article ID 537205), 12 pages, July 2015','2015-07',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(646,'domestic-journal-conf','IoT(사물기반 인터넷) 기반 헬스케어 서비스: 일상 생활습관 ‧ 건강관리 중심으로',NULL,'생명공학정책연구센터(BioINpro)','BioINpro 제 13호, pp. 1 ~ 12, 2015년 6월','2015-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(647,'domestic-journal-conf','Mobility Support Using Locator-Identifier Separation Protocol in 4G Mobile Communication Networks',NULL,'Telecommunications Review','제 25권 제 2호, pp. 337 ~ 355, 2015년 4월 (KCI)','2015-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(648,'intl-journal-conf','Fast Device Discovery for Remote Device Management in Lighting Control Networks',NULL,'Journal of Information Processing Systems','Vol. 11, No. 1, pp. 125~133, March 2015 (SCOPUS)','2015-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(649,'domestic-journal-conf','사물인터넷 기반 헬스케어 서비스 및 플랫폼 동향',NULL,'한국통신학회지(정보와통신)','제 31권 제 12호, pp. 25 ~ 30, 2014년 12월','2014-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(650,'domestic-journal-conf','모바일 중심 미래 인터넷: OpenFlow 기반 구현 및 KOREN 테스트베드 실험',NULL,'정보과학회논문지: 정보통신','제 41권 제 4호, pp. 167 ~ 176, 2014년 8월 (KCI)','2014-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(651,'domestic-journal-conf','Load Balancing for Proxy Mobile IPv6 in SAE-based Mobile Networks',NULL,'Telecommunications Review','제 24권 제 3호, pp. 433 ~ 448, 2014년 6월 (KCI)','2014-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(652,'domestic-journal-conf','LED 기반 조명 제어를 위한 PLASA 표준 프로토콜 기술',NULL,'조명전기설비학회지','제 28권 제 3호, pp. 9 ~ 21, 2014년 5월','2014-05',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(653,'intl-journal-conf','Performance Analysis of Distributed Mapping System in ID/Locator Separation Architectures',NULL,'Journal of Network and Computer Applications','Vol. 39, pp. 223~232, March 2014','2014-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(654,'intl-journal-conf','A Distributed Mobility Control Scheme in LISP Networks',NULL,'Wireless Networks','Vol. 20, No. 2, pp. 245~259, February 2014','2014-02',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(655,'intl-journal-conf','Distributed Mapping Management of Identifiers and Locators in Mobile-Oriented Internet Environment',NULL,'International Journal of Communication Systems','Vol. 27, No. 1, pp. 95~115, January 2014','2014-01',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(656,'intl-journal-conf','A Network-Based Handover Scheme in HIP-Based Mobile Networks',NULL,'Journal of Information Processing Systems','Vol. 9, No. 4, pp. 651~659, December 2013 (SCOPUS)','2013-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(657,'intl-journal-conf','Distributed Mapping Management of Identifiers and Locators in LISP-based Mobile Networks',NULL,'Wireless Personal Communications','Vol. 72, No. 1, pp. 565~579, September 2013','2013-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(658,'domestic-journal-conf','5세대 이동통신에서의 네트워크 이슈',NULL,'정보과학회지','제 31권 제 9호, pp. 20 ~ 26, 2013년 9월','2013-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(659,'intl-journal-conf','Mobile Oriented Future Internet (MOFI): Architectural Design and Implementations',NULL,'ETRI Journal','Vol. 35, No. 4, pp. 666~676, August 2013','2013-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(660,'domestic-journal-conf','무선 인터넷 환경에서 SCTP 프로토콜의 성능 최적화 방안',NULL,'Telecommunications Review','제 23권 제 3호, pp. 381 ~ 392, 2013년 6월 (KCI)< 무선 네트워크 환경에서 안드로이드 기반 SCTP 프로토콜의 성능 분석 정보과학회논문지: 시스템 및 이론, 제 40권 제 2호, pp. 105 ~ 110, 2013년 4월 (KCI)','2013-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(661,'domestic-journal-conf','조명 제어 네트워크에서 디바이스 관리를 위한 표준 프로토콜 기술 동향',NULL,'조명전기설비학회지','제 27권 제 2호, pp. 56 ~ 65, 2013년 3월','2013-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(662,'domestic-journal-conf','ID-LOC 분리 기반 인터넷 구조에서 분산형 매핑 시스템의 구현 및 평가',NULL,'한국통신학회논문지','제 37B권(네트워크 및 서비스) 제 11호, pp. 984 ~ 992, 2012년 11월 (KCI)','2012-11',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(663,'domestic-journal-conf','이동통신 로밍 환경에서 빠른 홈망 복귀를 위한 망탐색 알고리즘',NULL,'정보처리학회논문지','제 19-C권 제 2호, pp. 149 ~ 152, 2012년 4월 (KCI)','2012-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(664,'domestic-journal-conf','미래 인터넷의 이동 네트워크 구조 및 연구동향',NULL,'한국통신학회지(정보와통신)','제 29권 제 3호, pp. 41 ~ 48, 2012년 3월','2012-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(665,'domestic-journal-conf','모바일 단말 환경에서 웹브라우저의 로딩속도 개선 기법',NULL,'Telecommunications Review','제 22권 제 1호, pp. 139 ~ 152, 2012년 2월 (KCI)','2012-02',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(666,'intl-journal-conf','Network-Based Distributed Mobility Control in Localized Mobile LISP Networks',NULL,'IEEE Communications Letters','Vol. 16, No. 1, pp. 104~107, January 2012','2012-01',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(667,'intl-journal-conf','Partial Bicasting with Buffering for Proxy Mobile IPv6 Handover in Wireless Networks',NULL,'Journal of Information Processing Systems','Vol. 7, No. 4, pp. 627~634, December 2011 (SCOPUS)','2011-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(668,'domestic-journal-conf','Binding Query를 활용한 Proxy Mobile IPv6의 성능 향상 기법',NULL,'한국통신학회논문지','제 36권 제 11호(네트워크 및 융합서비스), pp. 1269 ~ 1276, 2011년 11월 (KCI)','2011-11',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(669,'domestic-journal-conf','이동 LISP망에서 네트워크 기반 이동성 제어 기법',NULL,'정보처리학회논문지','제 18-C권 제 5호, pp. 339 ~ 342, 2011년 10월 (KCI)','2011-10',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(670,'intl-journal-conf','Distributed Mobility Control in Proxy Mobile IPv6 Networks',NULL,'IEICE Transactions on Communications','Vol. E94-B, No. 8, pp. 2216~2224, August 2011','2011-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(671,'intl-journal-conf','Adaptive Congestion Control of mSCTP for Vertical Handover Based on Bandwidth Estimation in Heterogeneous Wireless Networks',NULL,'Wireless Personal Communications','Vol. 57, No. 4, pp. 707~725, April 2011','2011-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(672,'domestic-journal-conf','MOFI: Future Internet Architecture with Address-free Hosts for Mobile Environments',NULL,'Telecommunications Review','제 21권 제 2호, pp. 343 ~ 358, 2011년 4월 (KCI)','2011-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(673,'domestic-journal-conf','미래 인터넷을 위한 네이밍과 어드레싱을 위한 연구',NULL,'정보과학회지','제 29권 제 3호, pp. 53 ~ 65, 2011년 3월','2011-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(674,'domestic-journal-conf','Mobile Oriented Future Internet (MOFI): Design Considerations and Architecture',NULL,'OSIA Standards & Technology Review (ISSN:1738-9887)','Vol. 24, No. 1, pp. 61 ~ 77, 2011년 3월','2011-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(675,'domestic-journal-conf','Fast Tree Join for Seamless Multicast Handover in FMIPv6-based Mobile Networks',NULL,'Telecommunications Review','제 20권 제 6호, pp. 993 ~ 1003, 2010년 12월 (KCI)','2010-12',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(676,'intl-journal-conf','Multicast Handover Agents for Fast Handover in Wireless Multicast Networks',NULL,'IEEE Communications Letters','Vol. 14, No. 7, pp. 676~678, July 2010','2010-07',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(677,'intl-journal-conf','Fast Selective ACK Scheme for Throughput Enhancement of Multi-Homed SCTP Hosts',NULL,'IEEE Communications Letters','Vol. 14, No. 6, pp. 587~589, June 2010','2010-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(678,'intl-journal-conf','Performance Enhancement of mSCTP for Vertical Handover across Heterogeneous Wireless Networks',NULL,'International Journal of Communication Systems','Vol. 22, No. 12, pp. 1573~1591, December 2009','2009-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(679,'domestic-journal-conf','모바일 IPTV 멀티캐스트 전송 및 서비스 기술 동향',NULL,'정보과학회지','제 27권 제 8호, pp. 67 ~ 73, 2009년 8월','2009-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(680,'domestic-journal-conf','이동통신망에서의 모바일 IPTV 표준기술',NULL,'개방형컴퓨터통신연구회(OSIA) Standards & Technology Review','2009년 제 2호, pp. 49 ~ 59, 2009년 6월','2009-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(681,'intl-journal-conf','Corruption-aware Adaptive Increase and Adaptive Decrease Algorithm for TCP Error and Congestion Controls in Wireless Networks',NULL,'International Journal of Communication Systems','Vol. 22, No. 5, pp. 543~564, May 2009','2009-05',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(682,'domestic-journal-conf','A Technique to Enable the Corruption-aware Transport Protocols in Realistic Networks',NULL,'Telecommunications Review','제 19권 제 1호, pp. 129 ~ 137, 2009년 2월 (KCI)','2009-02',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(683,'intl-journal-conf','Partial CRC Checksum of SCTP for Error Control over Wireless Networks',NULL,'Wireless Personal Communications','Vol. 48, No. 2, pp. 247~260, January 2009','2009-01',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(684,'domestic-journal-conf','A Cross-layer Approach for Throughput Enhancement in Unsteady Satellite Networks',NULL,'Telecommunications Review','제 18권 제 6호, pp. 1089 ~ 1098, 2008년 12월 (KCI)','2008-12',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(685,'domestic-journal-conf','미래인터넷 이동성 제어 프레임워크',NULL,'Telecommunications Review','제 18권 제 5호, pp. 799 ~ 812, 2008년 10월 (KCI)','2008-10',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(686,'intl-journal-conf','Analysis of Handover Latency for Mobile IPv6 and mSCTP',NULL,'Journal of Information Processing Systems','Vol. 4, No. 3, pp. 87~96, September 2008 (SCOPUS)','2008-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(687,'domestic-journal-conf','리눅스 환경에서 SCTP와 TCP 프로토콜의 성능 비교',NULL,'한국통신학회논문지:네트워크및서비스','제 33권 제 8호, pp. 699 ~ 706, 2008년 8월 (KCI)','2008-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(688,'domestic-journal-conf','3G-WiBro 망간 수직핸드오버를 위한 mSCTP 기법',NULL,'정보과학회논문지:정보통신','제 35권 제 4호, pp. 355 ~ 365, 2008년 8월 (KCI)','2008-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(689,'domestic-journal-conf','ITU-T FMC 표준화 현황 및 국내 대응방안',NULL,'Telecommunications Review','제 18권 제 4호, pp. 583 ~ 592, 2008년 8월 (KCI)','2008-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(690,'intl-journal-conf','mSIP: Extension of SIP for Soft Handover with Bicasting',NULL,'IEEE Communications Letters','Vol. 12, No. 7, pp. 532~534, July 2008','2008-07',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(691,'domestic-journal-conf','Standardization on Mobility Management Architectures and Protocols for All-IP Mobile Networks',NULL,'Telecommunications Review','제 18권 제 3호, pp. 508 ~ 516, 2008년 6월 (KCI)','2008-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(692,'domestic-journal-conf','ITU-T NGN-GSI 이동성 관리 표준개발 동향',NULL,'개방형컴퓨터통신연구회(OSIA) Standards & Technology Review','2008년 제 1호, pp. 37 ~ 44, 2008년 3월','2008-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(693,'domestic-journal-conf','CC-SCTP: Chunk Checksum of SCTP for Enhancement of Throughput in Wireless Network Environment',NULL,'Telecommunications Review','제 17권 제 4호, pp. 690 ~ 699, 2007년 8월 (KCI)','2007-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(694,'domestic-journal-conf','ITU-T SG19 이동성 관리기술 표준화 동향',NULL,'개방형컴퓨터통신연구회(OSIA) Standards & Technology Review','2007년 제 2호, pp. 13 ~ 26, 2007년 6월','2007-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(695,'domestic-journal-conf','SCTP 기반 수송계층 이동성 기술',NULL,'정보과학회 정보통신기술(정보통신연구회)','제 20권 제 1호, pp. 64 ~ 79, 2007년 5월','2007-05',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(696,'domestic-journal-conf','IP 핸드오버: 망기반 기법 versus 종단간 기법',NULL,'한국통신학회지(정보와통신)','제 24권 제 4호, pp. 106 ~ 115, 2007년 4월','2007-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(697,'intl-journal-conf','Chunk Checksum of SCTP for Throughput Enhancement',NULL,'IEEE Communications Letters','Vol. 10, No. 11, pp. 796~798, November 2006','2006-11',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(698,'domestic-journal-conf','ITU-T에서의 B3G 이동성관리 표준 기술 분석',NULL,'Telecommunications Review','제 16권 제 3호, pp. 460 ~ 469, 2006년 6월 (KCI)','2006-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(699,'domestic-journal-conf','멀티홈잉 기반 SCTP 성능 실험 및 비교 분석',NULL,'정보처리학회논문지','제 13-C권 제 2호, pp. 235 ~ 240, 2006년 4월 (KCI)','2006-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(700,'intl-journal-conf','Mobility Management Requirements and Framework for Systems Beyond IMT-2000',NULL,'Journal of Communications and Networks','Vol. 7, No. 2, pp. 171~177, June 2005','2005-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(701,'intl-journal-conf','Transport Layer Mobility Support Utilizing Link Signal Strength Information',NULL,'IEICE Transactions on Communications','Vol. E87-B, No. 9, pp. 2548~2556, September 2004','2004-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(702,'domestic-journal-conf','mSCTP를 이용한 종단간 이동성 지원 방안',NULL,'정보과학회논문지,','제 31권 제 4호, pp. 393 ~ 404, 2004년 8월 (KCI)','2004-08',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(703,'intl-journal-conf','Limiting the Length of BET for Tunnel-Based IP Fast Handover',NULL,'IEICE Transactions on Fundamentals of Electronics Communications and Computer Sciences','Vol. E87-A, No. 6, pp. 1527~1530, June 2004','2004-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(704,'domestic-journal-conf','SCTP의 멀티호밍 특성에 대한 성능 평가',NULL,'정보처리학회논문지,','제11-C권 제2호, pp. 245 ~ 252, 2004년 4월 (KCI)','2004-04',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(705,'intl-journal-conf','mSCTP for Soft Handover in Transport Layer',NULL,'IEEE Communications Letters','Vol. 8, No. 3, pp. 189~191, March 2004','2004-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(706,'domestic-journal-conf','ECTP 멀티캐스트 전송 프로토콜 : 구현 및 성능분석',NULL,'한국통신학회논문지','제 28권 제 10호, pp. 876 - 890, 2003년 10월 (KCI)','2003-10',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(707,'domestic-journal-conf','3G-WLAN 연동기술 동향',NULL,'ETRI 전자통신동향분석','제 18권 4호 (통권 82호), pp. 1 - 10, 2003년 8월','2003-08',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(708,'domestic-journal-conf','SCTP 표준기술 분석 및 전망',NULL,'ETRI 전자통신동향분석','제 18권 3호 (통권 81호), pp. 11 - 21, 2003년 6월','2003-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(709,'domestic-journal-conf','인터넷 멀티캐스트 현황 및 전망',NULL,'주간기술동향','제 1090호, pp. 1 - 15, 2003년 4월','2003-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(710,'domestic-journal-conf','IP 멀티캐스트 시장 전망에 대한 고찰',NULL,'한국통신학회지(정보통신)','제 19권 제 10호, pp. 1577 ~ 1590, 2002년 10월','2002-10',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(711,'domestic-journal-conf','Subnet Multicast for Delivery of One-to-Many Multicast Applications',NULL,'Telecommunications Review','제 12권 제 5호, pp. 770 - 779, 2002년 10월 (KCI)','2002-10',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(712,'domestic-journal-conf','인터넷방송을 위한 멀티캐스트 기술 동향',NULL,'ETRI 전자통신동향분석','제 17권 3호 (통권 75호), 2002년 6월','2002-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(713,'domestic-journal-conf','종단간 서비스품질 표준기술 동향',NULL,'주간기술동향','제 1049호, 2002년 6월','2002-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(714,'domestic-journal-conf','신뢰적인 멀티캐스트 전송 프로토콜을 위한 Top-Down 기반의 제어 트리 구축 방안',NULL,'정보과학회논문지','제 28권, 4호, pp. 611 - 620, 2001년 12월 (KCI)','2001-12',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(715,'domestic-journal-conf','종단간 멀티캐스트 전송을 위한 ECTP 표준 프로토콜',NULL,'주간기술동향','제 1016호, 2001년 10월','2001-10',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(716,'intl-journal-conf','Configuration of ACK Trees for Multicast Transport Protocols',NULL,'ETRI Journal','Vol. 23, No. 3, pp. 111~120, September 2001','2001-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(717,'domestic-journal-conf','Enhanced Core Based Tree for Many-to-Many IP Multicasting',NULL,'Telecommunications Review','제 11권 제 3호, pp. 485 - 493, 2001년 6월 (KCI)','2001-06',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(718,'domestic-journal-conf','멀티캐스팅 프로토콜의 트리구성에 관한 성능평가 및 분석',NULL,'한국통신학회논문지','제 26권 제 5호, pp. 738 - 744, 2001년 5월 (KCI)','2001-05',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(719,'intl-journal-conf','Assignment of ADM Rings and DCS Mesh in Telecommunication Networks',NULL,'Journal of the O.R. Society','Vol. 52, No. 4, pp. 440~448, April 2001','2001-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(720,'intl-journal-conf','Multicast Delivery Based on Unicast and Subnet Multicast',NULL,'IEEE Communications Letters','Vol. 5, No. 4, pp. 181~183, April 2001','2001-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(721,'domestic-journal-conf','인터넷 멀티캐스트 신기술 동향',NULL,'ETRI 전자통신동향분석','제 16권 제 2호, pp. 1-9, 2001년 4월','2001-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(722,'intl-journal-conf','Dynamic Bandwidth Allocation Scheme for Multiple Real-time VBR Videos over ATM Networks',NULL,'Telecommunications Systems','Vol. 15, pp.359~380, December 2000','2000-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(723,'domestic-journal-conf','차세대 인터넷 멀티캐스팅 기술 동향',NULL,'한국통신학회지(정보통신)','제 17권 제 9호, pp. 168-187, 2000년 9월','2000-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(724,'domestic-journal-conf','인터넷 멀티캐스트 라우팅 기술 동향',NULL,'ETRI 전자통신동향분석','제 15권 제 3호, pp. 28-41, 2000년 6월','2000-06',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(725,'domestic-journal-conf','멀티캐스트 신뢰전송 기술 및 표준화 동향',NULL,'주간기술동향','제 944호, pp. 16 - 33, 2000년 5월','2000-05',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(726,'domestic-journal-conf','인터넷 전화 시장 및 표준화 동향',NULL,'ETRI 전자통신동향분석','제15권 2호, pp. 1 - 14, 2000년 4월호','2000-04',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(727,'intl-journal-conf','Minimizing Cost and Delay in Shared Multicast Trees',NULL,'ETRI Journal','Vol. 22, No. 1, pp.30~37, March 2000','2000-03',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(728,'domestic-journal-conf','액티브 네트워크 구조상에서 공동작업 응용을 위한 성능 향상 기법',NULL,'한국통신학회논문지','Vol. 24, No. 12B, pp. 2283 - 2291, 1999년 12월 (KCI)','1999-12',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(729,'domestic-journal-conf','다자간 멀티미디어 응용의 멀티캐스트 통신환경을 위한 통합관리 플랫폼',NULL,'한국통신학회논문지','Vol. 24, No. 12B, pp. 2262 - 2274, 1999년 12월 (KCI)','1999-12',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(730,'domestic-journal-conf','ATM 기반 MPLS 공중망에서의 IP 전송기술',NULL,'한국통신학회지(정보통신)','Vol. 16, No. 12, pp. 47 - 58, 1999년 12월','1999-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(731,'domestic-journal-conf','공중 ATM 망에서의 IP 전송기술 동향',NULL,'주간기술동향','924호, pp. 15 - 29, 1999년 12월','1999-12',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(732,'domestic-journal-conf','A Control of Channel Rate for Real-time VBR Video Transmission',NULL,'한국경영과학회논문지','제 24권 제 3호, pp. 63 - 72, 9월, 1999. (KCI)','1999-09',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(733,'intl-journal-conf','Non-Core Based Shared Tree Architecture for IP Multicasting',NULL,'Electronics Letters','Vol. 35, No. 11, pp. 872~873, May 1999','1999-05',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(734,'domestic-journal-conf','인터넷 멀티캐스트 라우팅 프로토콜 분석',NULL,'ETRI 전자통신동향분석','99년 10월호, pp. 99 - 110, 1999.','1999',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(735,'domestic-journal-conf','멀티캐스트 전송을 위한 오류제어 기법의 분류',NULL,'ETRI 전자통신동향분석','99년 6월호, pp. 76 - 84, 1999.','1999',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(736,'intl-journal-conf','A Design of the Self-Healing ATM Networks Based on the Backup Virtual Path',NULL,'Computers and Operations Research','Vol. 25, No. 7/8, pp. 595~609, July 1998','1998-07',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(737,'intl-journal-conf','A Design of the Minimum Cost Ring-Chain Network with Dual-Homing Survivability: Tabu Search Approach',NULL,'Computers and Operations Research','Vol. 24, No. 9, pp. 883~897, September 1997','1997-09',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(738,'domestic-journal-conf','Design of Survivable Communication Networks with High Connectivity Constraints',NULL,'한국경영과학회논문지','Vol. 22, No. 3, pp. 59-80, September, 1997. (KCI)','1997-09',1,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(739,'intl-journal-conf','A Tabu Search for the Survivable Fiber Optic Communication Network Design',NULL,'Computers and Industrial Engineering','Vol. 28, Issue 4, pp. 689~700, October 1995','1995-10',0,NULL,0,'2026-08-24 09:22:36');
INSERT INTO publications VALUES(740,'domestic-journal-conf','Heuristic Aspects of the Branch and Bound Procedure For a Job Scheduling Problem',NULL,'대한산업공학회지','Vol. 18, No. 2, 141-147, 1992. (KCI)','1992',1,NULL,0,'2026-08-24 09:22:36');
CREATE TABLE patents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
inventors TEXT NOT NULL,
application_no TEXT NOT NULL,
application_at TEXT NOT NULL,
registration_no TEXT,
registration_at TEXT,
country TEXT NOT NULL,
published_at TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO patents VALUES(301,'국내 지역별 태양광 발전량 및 전력 단가 예측 웹 서비스 제공 장치 및 그 방법','고석주, 김경훈, 김나현, 정수인, 홍일표','2025-0200788','2025-12-16',NULL,NULL,'한국','2025-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(302,'사물인터넷 네트워크 환경에서 QUIC 기반으로 CoAP 메시지를 중계하는 시스템','고석주, 정중화, 남혜빈, 최동규, 김민지','2023-0180751','2023-12-13','2963109','2026-05-06','한국','2023-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(303,'엣지 컴퓨팅을 위한 사물인터넷 서비스 시스템','고석주, 정중화, 남혜빈, 최동규','2023-0161365','2023-11-20',NULL,NULL,'한국','2023-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(304,'무선 네트워크 환경에서 핸드오버 및 커넥션 마이그레이션을 지원하는 장치 및 방법','고석주, 김소용, 모닙 고하르, 최동규','2023-0063455','2023-05-17',NULL,NULL,'한국','2023-05','2026-08-24 09:22:36');
INSERT INTO patents VALUES(305,'제한된 통신 환경에서 브로커 서버를 이용한 스마트 약상자 제어 시스템 및 방법','고석주, 김근수, 김철민, 김경식, 나재욱, 박진호','2020-0164156','2020-11-30','2384614','2022-04-05','한국','2020-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(306,'단방향 가시광 통신 환경에서 데이터 전송 신뢰성 개선을 위한 방법 및 이를 이용한 하이브리드','고석주, 김소용, 최동규, 남혜빈, 정중화, 김창묵','2020-0164152','2020-11-30','2420614','2022-07-08','한국','2020-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(307,'사물 인터넷 서비스를 제공하기 위한 QUIC-Proxy를 이용한 데이터 전달 방법 및 장치','고석주, 김소용, 최동규, 남혜빈, 정중화','2020-0164141','2020-11-30','2345473','2021-12-27','한국','2020-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(308,'금연구역 관리 서비스 방법 및 시스템','고석주, 이정우, 이용호, 권오상, 정서영, 김경식','2020-0101365','2020-08-12','2375686','2022-03-14','한국','2020-08','2026-08-24 09:22:36');
INSERT INTO patents VALUES(309,'사물 인터넷 환경에서 CoAP 기반의 데이터 스트리밍 방법 및 통신 시스템','고석주, 정중화, 최동규, 남혜빈, 이채현','2020-0094058','2020-07-28','2375703','2022-03-14','한국','2020-07','2026-08-24 09:22:36');
INSERT INTO patents VALUES(310,'인공지능 기반 적외선 카메라 센싱 시스템 및 방법','고석주, 홍성기, 김희원, 박명훈, 권민철','2019-0174964','2019-12-26',NULL,NULL,'한국','2019-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(311,'폭력 행위 관리 시스템 및 방법','고석주, 김동욱, 윤서원, 구영준, 성경화, 경예지','2019-0122161','2019-10-02','2264275','2021-06-07','한국','2019-10','2026-08-24 09:22:36');
INSERT INTO patents VALUES(312,'차량 인포테인먼트 관리 시스템','고석주, 최동규, 정중화, 남혜빈, 손종명','2019-0120044','2019-09-27','2410024','2022-06-13','한국','2019-09','2026-08-24 09:22:36');
INSERT INTO patents VALUES(313,'차량 인포테인먼트 마스터 장치 및 이를 포함하는 차량 인포테인먼트 통합 관리 시스템','고석주, 최동규, 정중화, 남혜빈, 신호경','2019-0059551','2019-05-21','2251310','2021-05-06','한국','2019-05','2026-08-24 09:22:36');
INSERT INTO patents VALUES(314,'CoAP 기반의 센싱 정보 스트리밍 방법','고석주, 최동규, 정중화, 남혜빈','2018-0168580','2018-12-24',NULL,NULL,'한국','2018-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(315,'이미지에서 기계학습 기법을 활용한 특정 부품영역 탐지 방법','고석주, 김대기, 김동인, 방종원, 우진철, 정중화','2018-0167974','2018-12-21',NULL,NULL,'한국','2018-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(316,'블록체인 기반의 재정장부 관리 시스템','고석주, 신승민, 서상민, 송동훈, 최효선, 그레고즈 리뼤시치','2018-0167970','2018-12-21',NULL,NULL,'한국','2018-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(317,'블록체인을 이용한 게임정보 처리 방법','고석주, 정재훈, 서창호, 노경환, 원응호','2018-0167951','2018-12-21',NULL,NULL,'한국','2018-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(318,'가시광 통신을 이용한 서비스 제공 시스템 및 그 방법','고석주, 김철민, 김소용, 모닙고하르','2018-0155013','2018-12-05',NULL,NULL,'한국','2018-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(319,'차량 인포테인먼트 시스템','고석주, 최동규, 정중화, 정민우','2018-0083515','2018-07-18',NULL,NULL,'한국','2018-07','2026-08-24 09:22:36');
INSERT INTO patents VALUES(320,'CoAP 기반의 사물인터넷 기술을 이용한 센서 관리 방법 및 이를 이용한 시스템','고석주, 최동규, 정중화, 정민우','2018-0082485','2018-07-16','2071974','2020-01-23','한국','2018-07','2026-08-24 09:22:36');
INSERT INTO patents VALUES(321,'가시광 통신을 이용한 무선 인터넷 비밀번호 관리 시스템 및 방법','김소용, 김철민, 고석주','2018-0082463','2018-07-16',NULL,NULL,'한국','2018-07','2026-08-24 09:22:36');
INSERT INTO patents VALUES(322,'시각 장애인을 위한 가시광 통신 기반의 교통 신호 전달 시스템 및 방법','권경동, 금동우, 황지영, 채윤창, 김철민, 김소용, 고석주','2018-0079319','2018-07-09',NULL,NULL,'한국','2018-07','2026-08-24 09:22:36');
INSERT INTO patents VALUES(323,'데이터 전달 장치, 방법과 그를 이용한 사물 인터넷 시스템, 데이터 전달 방법을 실행하기 위한 프로그램이','고석주, 최동규, 정중화','2017-0153908','2017-11-17','1969652','2019-04-10','한국','2017-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(324,'분산된 게시-구독 기법을 이용한 CoAP 기반 사물 인터넷 시스템의 작동 방법','고석주, 정중화, 최동규','2017-0153357','2017-11-16','2031726','2019-10-07','한국','2017-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(325,'비콘 기반 식당 정보 서비스 시스템 및 방법','고석주, 안창준, 김혜경, 송명근, 최예찬, 허동','2017-0087265','2017-07-10',NULL,NULL,'한국','2017-07','2026-08-24 09:22:36');
INSERT INTO patents VALUES(326,'사물 인터넷 네트워크에서의 모바일 단말 이동성 제어 장치 및 방법','고석주, 최상일','2017-0064039','2017-05-24','1847081','2018-04-03','한국','2017-05','2026-08-24 09:22:36');
INSERT INTO patents VALUES(327,'데이터 전달 장치, 방법 및 그를 이용한 사물인터넷 시스템','고석주, 정중화, 강형우, 최동규','2016-0175882','2016-12-21','1972470','2019-04-19','한국','2016-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(328,'저전력 근거리 통신을 이용한 개인 정보 교환 방법 및 시스템, 이를 수행하기 위한 기록매체','고석주, 김지인, 김지희, 김송아, 박지혜, 이승일, 서대화','2016-0157411','2016-11-24','1784309','2017-09-27','한국','2016-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(329,'가시광 통신 기기 관리 방법 및 장치 (with 유양디앤유)','김상옥, 유병오, 윤상호, 노승완, 고석주, 최상일','2016-0157606','2016-11-24',NULL,NULL,'한국','2016-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(330,'가시광 통신 방법 및 장치 (with 유양디앤유)','김상옥, 유병오, 윤상호, 노승완, 고석주, 최상일','2016-0157576','2016-11-24',NULL,NULL,'한국','2016-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(331,'이동 단말 및 관리 서버의 제어 방법','고석주, 최상일','2016-0051126','2016-04-26','1836116','2018-03-02','한국','2016-04','2026-08-24 09:22:36');
INSERT INTO patents VALUES(332,'무선 통신을 이용한 범용 안내 서비스 제공 방법 및 시스템','고석주, 장호영, 이종훈, 박찬석, 서민영','2016-0013614','2016-02-03','1716661','2017-03-09','한국','2016-02','2026-08-24 09:22:36');
INSERT INTO patents VALUES(333,'디지털 도어락 시스템 및 그 제어 방법','고석주, 권다영, 노혜성, 이수정','2015-0163735','2015-11-23','1814555','2017-12-27','한국','2015-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(334,'무인 지상차량을 이용한 주차차량 관리 시스템','김인한, 권혜련, 이은섭, 고석주','2015-0008735','2015-01-19',NULL,NULL,'한국','2015-01','2026-08-24 09:22:36');
INSERT INTO patents VALUES(335,'ESL 신호를 이용하는 측위 방법, 이를 수행하기 위한 기록 매체, 단말기 및 시스템','고석주, 김지인, 이민형, 김종근, 박지수, 조정근, 이승일, 서대화','2014-0180037','2014-12-15','1596320','2016-02-16','한국','2014-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(336,'ESL 신호를 이용하여 위치 기반 서비스를 제공하는 스마트 장치','고석주, 김지인, 이민형, 김종근, 박지수, 조정근, 이승일, 서대화','2014-0180036','2014-12-15',NULL,NULL,'한국','2014-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(337,'3차원 형상 복원을 위한 볼륨 카빙 장치 및 그 방법','고석주, 하태윤, 서대화, 정귀영, 이상은, 김지인, 김한별','2014-0167063','2014-11-27',NULL,NULL,'한국','2014-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(338,'립모션 기기를 이용한 수화 번역 시스템 및 그 방법','고석주, 조재현, 서대화, 이동훈, 이상은, 김지인, 하대규','2014-0166166','2014-11-26',NULL,NULL,'한국','2014-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(339,'립모션 기기의 수화 번역 정확도 향상을 위한 수화 번역 시스템 및 그 방법','고석주, 조재현, 서대화, 이동훈, 이상은, 김지인, 하대규','2014-0166165','2014-11-26',NULL,NULL,'한국','2014-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(340,'저전력 근거리 통신을 이용한 개인 정보 교환 방법 및 시스템, 이를 수행하기 위한 기록매체','고석주, 김지희, 서대화, 박지혜, 이승일, 김지인, 김송아','2014-0164802','2014-11-24',NULL,NULL,'한국','2014-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(341,'차량 분산 방법 및 장치, 이를 수행하기 위한 기록매체','고석주, 김지인, 엄진욱, 김민규, 홍석진, 배정규, 서대화','2014-0161975','2014-11-19','1612047','2016-04-06','한국','2014-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(342,'신규 액세스 포인트의 위치 인식 방법 및 이를 이용하는 서버','고석주, 김우주, 이민형, 하태윤, 심대섭, 조정근, 강보영, 서대화','2013-0167207','2013-12-30','1568365','2015-11-05','한국','2013-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(343,'디바이스 탐색 장치 및 디바이스 탐색 방법','고석주, 이상헌, 최상일','2013-0140737','2013-11-19','1405248','2015-04-17','한국','2013-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(344,'오픈플로우 통신 시스템 및 방법','고석주, 김지인, 최낙중','2013-0140736','2013-11-19','1525047','2015-05-27','한국','2013-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(345,'이동통신 시스템의 추적 영역 코드 구성 장치 및 방법','고석주, 강형우, 백태산, 강현구','2013-0140734','2013-11-19','1538453','2015-07-15','한국','2013-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(346,'프로세스 모니터링과 키보드 잠금을 이용한 프로세스 관리 방법 및 프로세스 관리 장치','고석주, 박진호, 이재휘','2013-0108587','2013-09-10','1515493','2015-04-21','한국','2013-09','2026-08-24 09:22:36');
INSERT INTO patents VALUES(347,'공유기 탐지 장치 및 공유기 탐지 방법','고석주, 박진호, 김철민','2013-0091483','2013-08-01',NULL,NULL,'한국','2013-08','2026-08-24 09:22:36');
INSERT INTO patents VALUES(348,'호스트 식별 프로토콜 네트워크 환경의 통신 시스템 및 방법','고석주, 최상일','2012-0154836','2012-12-27','1405248','2014-06-02','한국','2012-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(349,'액세스 라우터 및 그를 이용한 핸드오버 제어 방법','고석주, 최낙중, 김지인','2012-0154835','2012-12-27','1447104','2014-09-26','한국','2012-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(350,'호스트 식별 프로토콜 네트워크 환경의 이동통신 시스템 및 방법','고석주, 김지인, 이상헌','2012-0146740','2012-12-14','1459628','2014-11-03','한국','2012-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(351,'라우터의 호스트 위치 관리 방법','고석주, 김지인','2012-0030058','2012-03-23','1356721','2014-01-20','한국','2012-03','2026-08-24 09:22:36');
INSERT INTO patents VALUES(352,'분산형 구조를 이용한 데이터 통신 방법','고석주, 모닙고하르, 이재경','2011-0111384','2011-10-28','1311864','2013-09-17','한국','2011-10','2026-08-24 09:22:36');
INSERT INTO patents VALUES(353,'라우터, 그것을 포함하는 통신 네트워크 시스템 및 그것의 이동성 제어 방법','고석주, 최상일','2011-0107533','2011-10-20','1329331','2013-11-07','한국','2011-10','2026-08-24 09:22:36');
INSERT INTO patents VALUES(354,'모바일 액세스 게이트웨이 및 이를 이용한 이동성 제어 방법','고석주, 김지인','2011-0057608','2011-06-14','1223047','2013-01-10','한국','2011-06','2026-08-24 09:22:36');
INSERT INTO patents VALUES(355,'멀티홈잉 환경에서 프록시 모바일 인터넷 프로토콜을 사용하는 이동통신 시스템 및 그것의 핸드오버 방법','고석주, 김지인','2011-0001905','2011-01-07','1189140','2012-10-02','한국','2011-01','2026-08-24 09:22:36');
INSERT INTO patents VALUES(356,'멀티캐스트 방법 및 억세스 게이트웨이','고석주, 모닙고하르, 이재경','2010-0137897','2010-12-29','1200407','2012-11-06','한국','2010-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(357,'프록시 모바일 인터넷 프로토콜을 사용하는 이동통신 시스템 및 그것의 핸드오버 방법','고석주, 김지인','2010-0108058','2010-11-02','1258238','2013-04-19','한국','2010-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(358,'이동 단말, 통신 네트워크 및 그것의 이동성 제어 방법','고석주, 최상일','2010-0107701','2010-11-01','1177354','2012-08-21','한국','2010-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(359,'빠른 핸드오버를 지원하기 위한 이동통신 시스템 및 방법','고석주, 모닙고하르, 박재완','2010-0039688','2010-04-28','1091397','2011-12-01','한국','2010-04','2026-08-24 09:22:36');
INSERT INTO patents VALUES(360,'이동 단말간 데이터 전송 시스템 및 그 방법','고석주, 권순홍','2009-0048797','2009-06-02','1078156','2011-10-24','한국','2009-06','2026-08-24 09:22:36');
INSERT INTO patents VALUES(361,'프록시 모바일 IPv6망내 이동단말간 통신 경로 최적화 시스템 및 그 방법','고석주, 김지인','2009-0048796','2009-06-02',NULL,NULL,'한국','2009-06','2026-08-24 09:22:36');
INSERT INTO patents VALUES(362,'이종 무선망간 수직 핸드오버를 위한 이동 SCTP의 적응적 혼잡 제어 방법 및 장치','고석주, 김동필','2009-0013072','2009-02-17','1048251','2011-07-04','한국','2009-02','2026-08-24 09:22:36');
INSERT INTO patents VALUES(363,'서비스 품질 향상을 위한 PR-SCTP 기반 실시간 멀티미디어 데이터 전송 방법','고석주, 김상태','2008-0135342','2008-12-29','1040780','2011-06-03','한국','2008-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(364,'이종 무선망간의 수직적 핸드오버를 위한 데이터 전송 방법 및 장치','고석주, 김동필','2008-0083995','2008-08-27','0980592','2010-08-31','한국','2008-08','2026-08-24 09:22:36');
INSERT INTO patents VALUES(365,'무선통신용 멀티캐스트 핸드오버 방법','고석주, 박재성','2008-0077918','2008-08-08',NULL,NULL,'한국','2008-08','2026-08-24 09:22:36');
INSERT INTO patents VALUES(366,'무선 인터넷 망에서의 바이캐스팅을 이용한 SIP 핸드오버 방법 및 이동 단말','고석주, 이동화','2008-0052456','2008-06-04','0987555','2010-10-06','한국','2008-06','2026-08-24 09:22:36');
INSERT INTO patents VALUES(367,'무선 인터넷 환경에서의 바이캐스팅 기반 SCTP 핸드오버 방법 및 이동 단말','고석주, 권순홍, 김동필','2008-0049203','2008-05-27','0980582','2010-08-31','한국','2008-05','2026-08-24 09:22:36');
INSERT INTO patents VALUES(368,'Proxy MIP 기반 무선 인터넷 망에서의 바이캐스팅을 이용한 핸드오버 방법 및 장치','고석주, 김지인','2008-0049153','2008-05-27',NULL,NULL,'한국','2008-05','2026-08-24 09:22:36');
INSERT INTO patents VALUES(369,'윈도우 기반의 이동 단말에서 SCTPLIB를 이용한 mSCTP 핸드오버 방법','김용진, 고석주, 이동화, 김상태','2007-0139730','2007-12-28',NULL,NULL,'한국','2007-12','2026-08-24 09:22:36');
INSERT INTO patents VALUES(370,'전송 처리율 향상을 위한 SCTP 우선경로 설정 방법','고석주, 주수경','2007-0102031','2007-10-10',NULL,NULL,'한국','2007-10','2026-08-24 09:22:36');
INSERT INTO patents VALUES(371,'무선 네트워크에서 패킷의 손상정보를 사용하는 TCP 혼잡제어 방법','고석주, 최린, 이동화, 김용진','2007-0061209','2007-06-21','0870619','2008-11-19','한국','2007-06','2026-08-24 09:22:36');
INSERT INTO patents VALUES(372,'이동 단말의 SCTP 핸드오버와 모바일 아이피의 연동 장치 및 그 방법','고석주, 김동필','2007-0050648','2007-05-25','0880112','2009-01-15','한국','2007-05','2026-08-24 09:22:36');
INSERT INTO patents VALUES(373,'청크첵섬을 사용하는 무선 인터넷 에스씨티피 송수신 시스템 및 방법','김용진, 정종일, 고석주','2006-0117121','2006-11-24','0780921','2007-11-23','한국','2006-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(374,'데이터 링크계층의 링크 정보를 이용하여 핸드오버를 수행하는 단말장치','고석주, 김동필','2005-0110213','2005-11-17','0685740','2007-02-15','한국','2005-11','2026-08-24 09:22:36');
INSERT INTO patents VALUES(375,'SCTP 기반의 핸드오버 기능을 구비한 단말장치 및 핸드오버 방법','고석주, 김동필','US 11-915457 (미국, 2007.11.26)','2005-05-27','US 8,644,248 (미국, 2014.2.4)','2007-01-26','한국','2005-05','2026-08-24 09:22:36');
CREATE TABLE semesters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
term TEXT NOT NULL CHECK (term IN ('Spring','Fall')),
UNIQUE(year, term)
);
INSERT INTO semesters VALUES(181,2026,'Spring');
INSERT INTO semesters VALUES(182,2025,'Fall');
INSERT INTO semesters VALUES(183,2025,'Spring');
INSERT INTO semesters VALUES(184,2024,'Fall');
INSERT INTO semesters VALUES(185,2024,'Spring');
INSERT INTO semesters VALUES(186,2023,'Fall');
INSERT INTO semesters VALUES(187,2023,'Spring');
INSERT INTO semesters VALUES(188,2022,'Fall');
INSERT INTO semesters VALUES(189,2022,'Spring');
INSERT INTO semesters VALUES(190,2021,'Fall');
INSERT INTO semesters VALUES(191,2021,'Spring');
INSERT INTO semesters VALUES(192,2020,'Fall');
INSERT INTO semesters VALUES(193,2020,'Spring');
INSERT INTO semesters VALUES(194,2019,'Fall');
INSERT INTO semesters VALUES(195,2019,'Spring');
INSERT INTO semesters VALUES(196,2018,'Fall');
INSERT INTO semesters VALUES(197,2018,'Spring');
INSERT INTO semesters VALUES(198,2017,'Fall');
INSERT INTO semesters VALUES(199,2017,'Spring');
INSERT INTO semesters VALUES(200,2016,'Fall');
INSERT INTO semesters VALUES(201,2016,'Spring');
INSERT INTO semesters VALUES(202,2015,'Fall');
INSERT INTO semesters VALUES(203,2015,'Spring');
INSERT INTO semesters VALUES(204,2014,'Fall');
INSERT INTO semesters VALUES(205,2014,'Spring');
INSERT INTO semesters VALUES(206,2013,'Fall');
INSERT INTO semesters VALUES(207,2013,'Spring');
INSERT INTO semesters VALUES(208,2012,'Fall');
INSERT INTO semesters VALUES(209,2012,'Spring');
INSERT INTO semesters VALUES(210,2011,'Fall');
INSERT INTO semesters VALUES(211,2011,'Spring');
INSERT INTO semesters VALUES(212,2010,'Fall');
INSERT INTO semesters VALUES(213,2010,'Spring');
INSERT INTO semesters VALUES(214,2009,'Fall');
INSERT INTO semesters VALUES(215,2009,'Spring');
INSERT INTO semesters VALUES(216,2008,'Fall');
INSERT INTO semesters VALUES(217,2008,'Spring');
INSERT INTO semesters VALUES(218,2007,'Fall');
INSERT INTO semesters VALUES(219,2007,'Spring');
INSERT INTO semesters VALUES(220,2006,'Fall');
INSERT INTO semesters VALUES(221,2006,'Spring');
INSERT INTO semesters VALUES(222,2005,'Fall');
INSERT INTO semesters VALUES(223,2005,'Spring');
INSERT INTO semesters VALUES(224,2004,'Fall');
INSERT INTO semesters VALUES(225,2004,'Spring');
CREATE TABLE courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
semester_id INTEGER NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name_en TEXT NOT NULL,
name_kr TEXT,
note TEXT,
display_order INTEGER NOT NULL DEFAULT 0
);
INSERT INTO courses VALUES(529,181,'COMP 0414','Computer Networks',NULL,NULL,1);
INSERT INTO courses VALUES(530,181,'ITEC 0401','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(531,182,'COMP 0432','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(532,182,'ITEC 0402','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(533,183,'ITEC 0201','Introduction to Computer Science and Engineering',NULL,NULL,1);
INSERT INTO courses VALUES(534,183,'EECS 0312','Network Programming',NULL,NULL,2);
INSERT INTO courses VALUES(535,183,'ITEC 0401','Capstone Design Project I',NULL,NULL,3);
INSERT INTO courses VALUES(536,183,'COMP 0461','Problem-based Engineering Training Experiment',NULL,NULL,4);
INSERT INTO courses VALUES(537,184,'COMP 0432','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(538,184,'BIDA 0201','Big Data Convergence Project',NULL,NULL,2);
INSERT INTO courses VALUES(539,184,'ITEC 0402','Capstone Design Project II',NULL,NULL,3);
INSERT INTO courses VALUES(540,184,'COMP 0460','Problem-based Engineering Training Experiment',NULL,NULL,4);
INSERT INTO courses VALUES(541,185,'BIDA 0201','Big Data Convergence Project',NULL,NULL,1);
INSERT INTO courses VALUES(542,185,'ITEC 0401','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(543,186,'SABBATICAL','No Class (Sabbatical)',NULL,'Sabbatical',1);
INSERT INTO courses VALUES(544,187,'EECS 0312','Network Programming',NULL,NULL,1);
INSERT INTO courses VALUES(545,187,'BIDA 0201','Big Data Convergence Project',NULL,NULL,2);
INSERT INTO courses VALUES(546,187,'CICT 0703','Software Convergence Project 3',NULL,NULL,3);
INSERT INTO courses VALUES(547,188,'CLTR 273001','Problem Solving and Computing',NULL,NULL,1);
INSERT INTO courses VALUES(548,188,'COMP 432001','Topics in Software',NULL,NULL,2);
INSERT INTO courses VALUES(549,188,'ITEC 401001','Capstone Design Project I',NULL,NULL,3);
INSERT INTO courses VALUES(550,189,'EECS 312002','Network Programming',NULL,NULL,1);
INSERT INTO courses VALUES(551,189,'ITEC 402001','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(552,189,'COMP 460001','Problem-based Engineering Training Experiment',NULL,NULL,3);
INSERT INTO courses VALUES(553,189,'CICT 701001','Software Convergence Project 1',NULL,NULL,4);
INSERT INTO courses VALUES(554,190,'ITEC 402001','Capstone Design Project II',NULL,NULL,1);
INSERT INTO courses VALUES(555,191,'CLTR 273001','Problem Solving and Computing',NULL,NULL,1);
INSERT INTO courses VALUES(556,191,'GLSO 218001','Software Convergence Design 1',NULL,NULL,2);
INSERT INTO courses VALUES(557,191,'ITEC 402001','Capstone Design Project II',NULL,NULL,3);
INSERT INTO courses VALUES(558,191,'COMP 778001','Advanced Mobile Computing',NULL,NULL,4);
INSERT INTO courses VALUES(559,192,'ITEC 401003','Capstone Design Project I',NULL,NULL,1);
INSERT INTO courses VALUES(560,192,'ITEC 401004','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(561,192,'GLSO 228001','Topics in Software Convergence IV',NULL,NULL,3);
INSERT INTO courses VALUES(562,193,'ITEC 401016','Capstone Design Project I',NULL,NULL,1);
INSERT INTO courses VALUES(563,193,'ITEC 402003','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(564,193,'COMP 778001','Advanced Mobile Computing',NULL,NULL,3);
INSERT INTO courses VALUES(565,194,'COMP 432001','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(566,194,'ITEC 401003','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(567,194,'ITEC 402016','Capstone Design Project II',NULL,NULL,3);
INSERT INTO courses VALUES(568,195,'ITEC 201010','Introduction to Computer Science and Engineering',NULL,NULL,1);
INSERT INTO courses VALUES(569,195,'ITEC 401016','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(570,195,'ITEC 402003','Capstone Design Project II',NULL,NULL,3);
INSERT INTO courses VALUES(571,196,'ITEC 401005','Capstone Design Project I',NULL,NULL,1);
INSERT INTO courses VALUES(572,196,'ITEC 402017','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(573,197,'ITEC 401021','Capstone Design Project I',NULL,NULL,1);
INSERT INTO courses VALUES(574,197,'ITEC 402006','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(575,198,'COMP 432001','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(576,198,'GLSO 213001','Creative Convergence Design',NULL,NULL,2);
INSERT INTO courses VALUES(577,198,'ITEC 401003','Capstone Design Project I',NULL,NULL,3);
INSERT INTO courses VALUES(578,199,'EECS 312001','Network Programming',NULL,NULL,1);
INSERT INTO courses VALUES(579,199,'ITEC 401018','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(580,199,'ITEC 402005','Capstone Design Project II',NULL,NULL,3);
INSERT INTO courses VALUES(581,199,'COME 742001','Advanced Computer Networks',NULL,NULL,4);
INSERT INTO courses VALUES(582,200,'COMP 432001','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(583,200,'COME 331013) and DS Applications (COMP 216002','Data Structures',NULL,NULL,2);
INSERT INTO courses VALUES(584,200,'ITEC 401004','Capstone Design Project I',NULL,NULL,3);
INSERT INTO courses VALUES(585,200,'ITEC 402016','Capstone Design Project II',NULL,NULL,4);
INSERT INTO courses VALUES(586,201,'COMP 414003','Computer Networks',NULL,NULL,1);
INSERT INTO courses VALUES(587,201,'ITEC 401031','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(588,201,'ITEC 402005','Capstone Design Project II',NULL,NULL,3);
INSERT INTO courses VALUES(589,201,'ITEC 402006','Capstone Design Project II',NULL,NULL,4);
INSERT INTO courses VALUES(590,202,'COMP 432001','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(591,202,'ITEC 401006','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(592,202,'ITEC 401007','Capstone Design Project I',NULL,NULL,3);
INSERT INTO courses VALUES(593,202,'ITEC 402022','Capstone Design Project II',NULL,NULL,4);
INSERT INTO courses VALUES(594,203,'ITEC 201009','Introduction to Computer Science and Engineering',NULL,NULL,1);
INSERT INTO courses VALUES(595,203,'EECS 312001','Network Programming',NULL,NULL,2);
INSERT INTO courses VALUES(596,203,'EECS 312002','Network Programming',NULL,NULL,3);
INSERT INTO courses VALUES(597,203,'ITEC 401021','Capstone Design Project I',NULL,NULL,4);
INSERT INTO courses VALUES(598,203,'ITEC 402004','Capstone Design Project II',NULL,NULL,5);
INSERT INTO courses VALUES(599,204,'ITEC 401006','Capstone Design Project I',NULL,NULL,1);
INSERT INTO courses VALUES(600,204,'ITEC 402021','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(601,204,'COME 742001','Advanced Computer Networks',NULL,NULL,3);
INSERT INTO courses VALUES(602,205,'COMP 414002','Computer Networks',NULL,NULL,1);
INSERT INTO courses VALUES(603,205,'ITEC 402004','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(604,206,'ITEC 401005','Capstone Design Project I',NULL,NULL,1);
INSERT INTO courses VALUES(605,206,'ITEC 402017','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(606,207,'COMP 432001','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(607,207,'ITEC 402005','Capstone Design Project II',NULL,NULL,2);
INSERT INTO courses VALUES(608,208,'COMP 432001','Topics in Software',NULL,NULL,1);
INSERT INTO courses VALUES(609,208,'ITEC 401003','Capstone Design Project I',NULL,NULL,2);
INSERT INTO courses VALUES(610,208,'EECS 422021','Senior Project II',NULL,NULL,3);
INSERT INTO courses VALUES(611,209,'COMP 214002','Web Programming Design',NULL,NULL,1);
INSERT INTO courses VALUES(612,209,'COMP 749001','Computer Network Protocols',NULL,NULL,2);
INSERT INTO courses VALUES(613,209,'ELEC 957001','Next Generation Internet Technology',NULL,NULL,3);
INSERT INTO courses VALUES(614,210,'COME 311003','Probability and Statistics: Class 1',NULL,NULL,1);
INSERT INTO courses VALUES(615,210,'COME 311004','Probability and Statistics: Class 2',NULL,NULL,2);
INSERT INTO courses VALUES(616,210,'COMP 764001','Internet Computing',NULL,NULL,3);
INSERT INTO courses VALUES(617,210,'COMP 432001','Topics in Software',NULL,NULL,4);
INSERT INTO courses VALUES(618,211,'COMP 214001','Web Programming Design: Class 1',NULL,NULL,1);
INSERT INTO courses VALUES(619,211,'COMP 214002','Web Programming Design: Class 2',NULL,NULL,2);
INSERT INTO courses VALUES(620,212,'COME 311003','Probability and Statistics',NULL,NULL,1);
INSERT INTO courses VALUES(621,212,'COME 331004','Data Structures',NULL,NULL,2);
INSERT INTO courses VALUES(622,212,'EECS 422022','Senior Project II',NULL,NULL,3);
INSERT INTO courses VALUES(623,213,'EECS 450001','Internet Programming Design',NULL,NULL,1);
INSERT INTO courses VALUES(624,213,'COMP 414003','Computer Networks',NULL,NULL,2);
INSERT INTO courses VALUES(625,213,'ELEC 957001','Next-Generation Internet Technology',NULL,NULL,3);
INSERT INTO courses VALUES(626,213,'EECS 408016','Senior Project I',NULL,NULL,4);
INSERT INTO courses VALUES(627,214,'COME 31103','Probability and Statistics',NULL,NULL,1);
INSERT INTO courses VALUES(628,214,'COME 33105','Data Structures',NULL,NULL,2);
INSERT INTO courses VALUES(629,214,'EECS 42202','Senior Project II',NULL,NULL,3);
INSERT INTO courses VALUES(630,215,'EECS 20704','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(631,215,'EECS 45001','Internet Programming Design',NULL,NULL,2);
INSERT INTO courses VALUES(632,215,'EECS 40803','Senior Project I',NULL,NULL,3);
INSERT INTO courses VALUES(633,216,'COME 31103','Probability and Statistics',NULL,NULL,1);
INSERT INTO courses VALUES(634,216,'COMP 76401','Internet Computing',NULL,NULL,2);
INSERT INTO courses VALUES(635,216,'ELEC 75401','Computer Network: Special',NULL,NULL,3);
INSERT INTO courses VALUES(636,217,'EECS 31401','Internet Programming and Practices',NULL,NULL,1);
INSERT INTO courses VALUES(637,217,'COMP 74901','Computer Network Protocols',NULL,NULL,2);
INSERT INTO courses VALUES(638,218,'EECS 20703','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(639,218,'COME 31103','Probability and Statistics',NULL,NULL,2);
INSERT INTO courses VALUES(640,218,'COMP 41401','Computer Networks',NULL,NULL,3);
INSERT INTO courses VALUES(641,219,'EECS 20109','C Programming and Practices',NULL,NULL,1);
INSERT INTO courses VALUES(642,219,'EECS 20704','Introduction to Computer Science',NULL,NULL,2);
INSERT INTO courses VALUES(643,219,'EECS 31401','Internet Programming and Practices',NULL,NULL,3);
INSERT INTO courses VALUES(644,220,'EECS 20703','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(645,220,'COME 31103','Probability and Statistics',NULL,NULL,2);
INSERT INTO courses VALUES(646,220,'COMP 76401','Internet Computing',NULL,NULL,3);
INSERT INTO courses VALUES(647,221,'EECS 20704','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(648,221,'EECS 31401','Internet Programming and Practices',NULL,NULL,2);
INSERT INTO courses VALUES(649,221,'COMP 75101','Computer Performance Analysis',NULL,NULL,3);
INSERT INTO courses VALUES(650,222,'EECS 20703','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(651,222,'EECS 20806','Data Structures',NULL,NULL,2);
INSERT INTO courses VALUES(652,222,'COMP 41402','Computer Network',NULL,NULL,3);
INSERT INTO courses VALUES(653,223,'EECS 20704','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(654,223,'EECS 49201','Internet Programming Design',NULL,NULL,2);
INSERT INTO courses VALUES(655,223,'COMP 74901','Computer Network Protocols',NULL,NULL,3);
INSERT INTO courses VALUES(656,224,'ELEC 26104','Computer Engineering',NULL,NULL,1);
INSERT INTO courses VALUES(657,224,'COMP 22101','Data Structures II',NULL,NULL,2);
INSERT INTO courses VALUES(658,224,'EECS 31201','Network Programming',NULL,NULL,3);
INSERT INTO courses VALUES(659,225,'EECS 20704','Introduction to Computer Science',NULL,NULL,1);
INSERT INTO courses VALUES(660,225,'COMP 21105','Data Structures I',NULL,NULL,2);
CREATE TABLE standards_bodies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org TEXT NOT NULL,
full_name TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('international','domestic')),
period TEXT NOT NULL,
role TEXT,
display_order INTEGER NOT NULL DEFAULT 0
);
INSERT INTO standards_bodies VALUES(25,'IEC TC100','Multimedia Systems and Equipment','international','2018 ~ Present','Convenor',1);
INSERT INTO standards_bodies VALUES(26,'ISO/IEC JTC1/SC41','Internet of Things and Digital Twin','international','2020 ~ Present',NULL,2);
INSERT INTO standards_bodies VALUES(27,'ITU-T SG20','IoT Services based on VLC','international','2018 ~ 2020',NULL,3);
INSERT INTO standards_bodies VALUES(28,'TTA/PG425','가시광 통신 기반 사물인터넷 서비스','domestic','2018 ~ 2020',NULL,4);
INSERT INTO standards_bodies VALUES(29,'ITU-T SG13','Mobility Management','international','2002 ~ 2012',NULL,5);
INSERT INTO standards_bodies VALUES(30,'ISO/IEC JTC1/SC6','WG7: Network and Transport','international','2000 ~ 2014',NULL,6);
CREATE TABLE standard_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id INTEGER NOT NULL REFERENCES standards_bodies(id) ON DELETE CASCADE,
wg TEXT NOT NULL,
project_name TEXT NOT NULL,
title TEXT NOT NULL,
doc_ref TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('IS','TS','TR','CDV','WDTR','InProgress')),
published_at TEXT
);
INSERT INTO standard_documents VALUES(177,25,'WG12','Multimedia Systems and Equipment for Metaverse (IEC 63614)','Multimedia Systems and Equipment for Metaverse - Part 1: Genaral','IEC TR 63614-1 (May 2026)','TR','2026-05');
INSERT INTO standard_documents VALUES(178,25,'WG12','Multimedia Systems and Equipment for Metaverse (IEC 63614)','Multimedia Systems and Equipment for Metaverse - Part 2: Classification','IEC TS 63614-2 (May 2026)','TS','2026-05');
INSERT INTO standard_documents VALUES(179,25,'WG12','Multimedia Systems and Equipment for Metaverse (IEC 63614)','Multimedia Systems and Equipment for Metaverse - Part 3: Gap Analysis','IEC TR 63614-3 (March 2026)','TR','2026-03');
INSERT INTO standard_documents VALUES(180,25,'WG12','Multimedia Systems and Equipment for Metaverse (IEC 63614)','Multimedia Systems and Equipment for Metaverse - Part 4: AI Use Cases','IEC WDTR 63614-4 (In Progress)','InProgress',NULL);
INSERT INTO standard_documents VALUES(181,25,'WG11(Convenor)','User''s QoE for Multimedia Conference Services (IEC 63478)','User''s QoE for Multimedia Conference Services - Part 1: General','IEC TR 63478-1 (November 2023)','TR','2023-11');
INSERT INTO standard_documents VALUES(182,25,'WG11(Convenor)','User''s QoE for Multimedia Conference Services (IEC 63478)','User''s QoE for Multimedia Conference Services - Part 2: Requirments','IEC IS 63478-2 (October 2025)','IS','2025-10');
INSERT INTO standard_documents VALUES(183,25,'WG11(Convenor)','User''s QoE for Multimedia Conference Services (IEC 63478)','User''s QoE for Multimedia Conference Services - Part 3: Measurement Methods','IEC CDV 63478-3 (In Progress)','InProgress',NULL);
INSERT INTO standard_documents VALUES(184,25,'WG30(TA17)','Infotainment Services for Public Vehicles (IEC 63479)','Infotainment Services for Public Vehicles (PVIS) - Part 1: General','IEC TR 63479-1 (November 2023)','TR','2023-11');
INSERT INTO standard_documents VALUES(185,25,'WG30(TA17)','Infotainment Services for Public Vehicles (IEC 63479)','Infotainment Services for Public Vehicles (PVIS) - Part 2: Requirements','IEC IS 63479-2 (February 2026)','IS','2026-02');
INSERT INTO standard_documents VALUES(186,25,'WG30(TA17)','Infotainment Services for Public Vehicles (IEC 63479)','Infotainment Services for Public Vehicles (PVIS) - Part 3: Framework','IEC IS 63479-3 (January 2026)','IS','2026-01');
INSERT INTO standard_documents VALUES(187,25,'WG30(TA17)','Configurable Car Infotainment Services (IEC 63246)','Configurable Car Infotainment Services (CCIS) - Part 1: General','IEC 63246-1 (August 2021)','IS','2021-08');
INSERT INTO standard_documents VALUES(188,25,'WG30(TA17)','Configurable Car Infotainment Services (IEC 63246)','Configurable Car Infotainment Services (CCIS) - Part 2: Requirements','IEC 63246-2 (January 2022)','IS','2022-01');
INSERT INTO standard_documents VALUES(189,25,'WG30(TA17)','Configurable Car Infotainment Services (IEC 63246)','Configurable Car Infotainment Services (CCIS) - Part 3: Framework','IEC 63246-3 (January 2022)','IS','2022-01');
INSERT INTO standard_documents VALUES(190,25,'WG30(TA17)','Configurable Car Infotainment Services (IEC 63246)','Configurable Car Infotainment Services (CCIS) - Part 4: Protocol','IEC TR 63246-4 (December 2022)','TR','2022-12');
INSERT INTO standard_documents VALUES(191,26,'WG','(WG5) IoT-based Management of Tangible Cultural Heritage Assets (ISO/IEC TR 30189)','Part 1: Framework','ISO/IEC TR 30189-1 (March 2025)','TR','2025-03');
INSERT INTO standard_documents VALUES(192,26,'WG','(WG5) IoT-based Management of Tangible Cultural Heritage Assets (ISO/IEC TR 30189)','Part 2: Use Cases','ISO/IEC CDTR 30189-2 (In Progress)','InProgress',NULL);
INSERT INTO standard_documents VALUES(193,27,'Main','IoT Services based on VLC','Framework of IoT Services based on Visible Light Communications (VLC)','ITU-T Recommendation Y.4465 (January 2020)','IS','2020-01');
INSERT INTO standard_documents VALUES(194,27,'Main','IoT Services based on VLC','Functional Architecture for IoT Services based on VLC','ITU-T Recommendation Y.4474 (August 2020)','IS','2020-08');
INSERT INTO standard_documents VALUES(195,28,'Main','가시광 통신 기반 사물인터넷 서비스','가시광 통신 기반 사물인터넷 서비스 - 제 1 부: 네트워크 모델 및 요구사항','TTAK.KO-10.1158-part1 (2019년 12월)','IS','2019-12');
INSERT INTO standard_documents VALUES(196,28,'Main','가시광 통신 기반 사물인터넷 서비스','가시광 통신 기반 사물인터넷 서비스 - 제 2 부: 프레임워크','TTAK.KO-10.1158-part2 (2020년 12월)','IS','2020-12');
INSERT INTO standard_documents VALUES(197,28,'Main','가시광 통신 기반 사물인터넷 서비스','가시광 통신 기반 사물인터넷 서비스 - 제 3 부: 프로토콜','TTAK.KO-10.1158-part3 (2020년 12월)','IS','2020-12');
INSERT INTO standard_documents VALUES(198,29,'Main','Mobility Management','NNI Mobility Management Requirements for SBI2K','ITU-T Supplement to Recommendation Q.Sup52 (December 2004)','IS','2004-12');
INSERT INTO standard_documents VALUES(199,29,'Main','Mobility Management','Mobility Management Requirements for Next-Generation Networks','ITU-T Recommendation Q.1706/Y.2801 (July 2006)','IS','2006-07');
INSERT INTO standard_documents VALUES(200,29,'Main','Mobility Management','Framework of IPv6 Multi-homing for NGN','ITU-T Recommendation Y.2052 (Feb. 2008)','IS','2008-02');
INSERT INTO standard_documents VALUES(201,29,'Main','Mobility Management','Generic Framework of Mobility Management for Next-Generation Networks','ITU-T Recommendation Q.1707/Y.2804 (Feb. 2008)','IS','2008-02');
INSERT INTO standard_documents VALUES(202,29,'Main','Mobility Management','Framework of Location Management for Next-Generation Networks','ITU-T Recommendation Q.1708/Y.2805 (October 2008)','IS','2008-10');
INSERT INTO standard_documents VALUES(203,29,'Main','Mobility Management','Framework of Handover Control for Next-Generation Networks','ITU-T Recommendation Q.1709/Y.2806 (October 2008)','IS','2008-10');
INSERT INTO standard_documents VALUES(204,29,'Main','Mobility Management','Framework of Mobility Management in Service Stratum for NGN','ITU-T Recommendation Y.2809 (November 2011)','IS','2011-11');
INSERT INTO standard_documents VALUES(205,30,'Enhanced Communication Transport Protocol (ECTP)','ISO/IEC 14476','ECTP-1: Specification of Simplex Multicast Transport','ITU-T Recommendation X.606 (October 2001) ISO/IEC IS 14476-1 (April 2002)','IS','2001-10');
INSERT INTO standard_documents VALUES(206,30,'Enhanced Communication Transport Protocol (ECTP)','ISO/IEC 14476','ECTP-2: Specification of QoS Management for Simplex Multicast Transport','ITU-T Recommendation X.606.1 (February 2003) ISO/IEC IS 14476-2 (June 2003)','IS','2003-02');
INSERT INTO standard_documents VALUES(207,30,'Enhanced Communication Transport Protocol (ECTP)','ISO/IEC 14476','ECTP-3: Specification of Duplex Multicast Transport','ITU-T Recommendation X.607 (February 2007) ISO/IEC IS 14476-3 (August 2008)','IS','2007-02');
INSERT INTO standard_documents VALUES(208,30,'Enhanced Communication Transport Protocol (ECTP)','ISO/IEC 14476','ECTP-4: Specification of QoS Management for Duplex Multicast Transport','ITU-T Recommendation X.607.1 (November 2008) ISO/IEC IS 14476-4 (April 2010)','IS','2008-11');
INSERT INTO standard_documents VALUES(209,30,'Enhanced Communication Transport Protocol (ECTP)','ISO/IEC 14476','ECTP-5: Specification of N-plex Multicast Transport','ITU-T Recommendation X.608 (February 2007) ISO/IEC IS 14476-5 (August 2008)','IS','2007-02');
INSERT INTO standard_documents VALUES(210,30,'Enhanced Communication Transport Protocol (ECTP)','ISO/IEC 14476','ECTP-6: Specification of QoS Management for N-plex Multicast Transport','ITU-T Recommendation X.608.1 (November 2008) ISO/IEC IS 14476-6 (April 2010)','IS','2008-11');
INSERT INTO standard_documents VALUES(211,30,'Relayed Multicast Protocol (RMCP)','ISO/IEC 16512','RMCP-1: Framework','ITU-T Recommendation X.603 (April 2004) ISO/IEC IS 16512-1','IS','2004-04');
INSERT INTO standard_documents VALUES(212,30,'Relayed Multicast Protocol (RMCP)','ISO/IEC 16512','RMCP-2: Specification of Simplex Group Applications','ITU-T Recommendation X.603.1 (February 2007) ISO/IEC IS 16512-2','IS','2007-02');
INSERT INTO standard_documents VALUES(213,30,'Relayed Multicast Protocol (RMCP)','ISO/IEC 16512','RMCP-3: Specification of N-plex Group Applications','ITU-T Recommendation X.603.2 (September 2010) ISO/IEC IS 16512-3','IS','2010-09');
INSERT INTO standard_documents VALUES(214,30,'Mobile Multicast Communications (MMC)','ISO/IEC 24793','MMC-1: MMC Framework','ITU-T Recommendation X.604 (March 2010) ISO/IEC IS 24793-1','IS','2010-03');
INSERT INTO standard_documents VALUES(215,30,'Mobile Multicast Communications (MMC)','ISO/IEC 24793','MMC-2: MMC Protocol over Native IP Multicast Networks','ITU-T Recommendation X.604.1 (March 2010) ISO/IEC IS 24793-2','IS','2010-03');
INSERT INTO standard_documents VALUES(216,30,'Mobile Multicast Communications (MMC)','ISO/IEC 24793','MMC-3: MMC Protocol over Overlay Multicast Networks','ITU-T Recommendation X.604.2 (September 2010) ISO/IEC IS 24793-3','IS','2010-09');
INSERT INTO standard_documents VALUES(217,30,'Future Network','Problem Statement and Requirements (FN-PSR): ISO/IEC TR 29181','Part 1: Overall Aspects','ISO/IEC TR 29181-1 (September 2012)','TR','2012-09');
INSERT INTO standard_documents VALUES(218,30,'Future Network','Problem Statement and Requirements (FN-PSR): ISO/IEC TR 29181','Part 2: Naming and Addressing','ISO/IEC TR 29181-2 (December 2014)','TR','2014-12');
INSERT INTO standard_documents VALUES(219,30,'Future Network','Problem Statement and Requirements (FN-PSR): ISO/IEC TR 29181','Part 3: Switching and Routing','ISO/IEC TR 29181-3 (April 2013)','TR','2013-04');
INSERT INTO standard_documents VALUES(220,30,'Future Network','Problem Statement and Requirements (FN-PSR): ISO/IEC TR 29181','Part 4: Mobility','ISO/IEC TR 29181-4 (December 2013)','TR','2013-12');
INSERT INTO sqlite_sequence VALUES('research_projects',17);
INSERT INTO sqlite_sequence VALUES('research_areas',81);
INSERT INTO sqlite_sequence VALUES('members',81);
INSERT INTO sqlite_sequence VALUES('alumni',301);
INSERT INTO sqlite_sequence VALUES('publications',741);
INSERT INTO sqlite_sequence VALUES('patents',375);
INSERT INTO sqlite_sequence VALUES('semesters',227);
INSERT INTO sqlite_sequence VALUES('courses',663);
INSERT INTO sqlite_sequence VALUES('standards_bodies',30);
INSERT INTO sqlite_sequence VALUES('standard_documents',221);
CREATE INDEX idx_publications_category ON publications(category);
CREATE INDEX idx_publications_highlight ON publications(is_highlight) WHERE is_highlight = 1;
CREATE INDEX idx_courses_semester ON courses(semester_id);
CREATE INDEX idx_stddocs_body ON standard_documents(body_id);
COMMIT;
+6
View File
@@ -16,6 +16,7 @@ type Config struct {
GinMode string
CORSAllowOrigins string
AutoMigrate bool
AdminToken string
}
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
@@ -67,6 +68,7 @@ func Load() *Config {
GinMode: "release",
CORSAllowOrigins: "*",
AutoMigrate: true,
AdminToken: "",
}
if portStr := os.Getenv("PORT"); portStr != "" {
@@ -91,6 +93,10 @@ func Load() *Config {
cfg.CORSAllowOrigins = corsStr
}
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
cfg.AdminToken = strings.Trim(token, " \"'\r\n\t")
}
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
}
+5
View File
@@ -18,6 +18,7 @@ func TestConfigDefaults(t *testing.T) {
_ = os.Unsetenv("GIN_MODE")
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
_ = os.Unsetenv("AUTO_MIGRATE")
_ = os.Unsetenv("ADMIN_TOKEN")
cfg := config.Load()
assert.Equal(t, 8080, cfg.Port)
@@ -26,6 +27,7 @@ func TestConfigDefaults(t *testing.T) {
assert.Equal(t, "release", cfg.GinMode)
assert.Equal(t, "*", cfg.CORSAllowOrigins)
assert.True(t, cfg.AutoMigrate)
assert.Equal(t, "", cfg.AdminToken)
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
}
@@ -36,6 +38,7 @@ func TestConfigEnvOverrides(t *testing.T) {
_ = os.Setenv("GIN_MODE", "debug")
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
_ = os.Setenv("AUTO_MIGRATE", "false")
_ = os.Setenv("ADMIN_TOKEN", "super-secret-token")
defer func() {
_ = os.Unsetenv("PORT")
_ = os.Unsetenv("HOST")
@@ -43,6 +46,7 @@ func TestConfigEnvOverrides(t *testing.T) {
_ = os.Unsetenv("GIN_MODE")
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
_ = os.Unsetenv("AUTO_MIGRATE")
_ = os.Unsetenv("ADMIN_TOKEN")
}()
cfg := config.Load()
@@ -52,6 +56,7 @@ func TestConfigEnvOverrides(t *testing.T) {
assert.Equal(t, "debug", cfg.GinMode)
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
assert.False(t, cfg.AutoMigrate)
assert.Equal(t, "super-secret-token", cfg.AdminToken)
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
}
+113
View File
@@ -1,7 +1,10 @@
package handlers
import (
"strconv"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/models"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
@@ -23,6 +26,61 @@ func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
httpx.OK(c, projects)
}
func (h *HomeHandler) CreateResearchProject(c *gin.Context) {
var req models.ResearchProject
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
httpx.BadRequest(c, "slug, title, and abstract are required")
return
}
id, err := h.repo.CreateResearchProject(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create research project: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *HomeHandler) UpdateResearchProject(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid project ID")
return
}
var req models.ResearchProject
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
httpx.BadRequest(c, "slug, title, and abstract are required")
return
}
req.ID = id
if err := h.repo.UpdateResearchProject(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update research project: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *HomeHandler) DeleteResearchProject(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid project ID")
return
}
if err := h.repo.DeleteResearchProject(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete research project: "+err.Error())
return
}
httpx.NoContent(c)
}
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
areas, err := h.repo.GetResearchAreas(c.Request.Context())
if err != nil {
@@ -32,6 +90,61 @@ func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
httpx.OK(c, areas)
}
func (h *HomeHandler) CreateResearchArea(c *gin.Context) {
var req models.ResearchArea
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.NameEn == "" {
httpx.BadRequest(c, "name_en is required")
return
}
id, err := h.repo.CreateResearchArea(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create research area: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *HomeHandler) UpdateResearchArea(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid area ID")
return
}
var req models.ResearchArea
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.NameEn == "" {
httpx.BadRequest(c, "name_en is required")
return
}
req.ID = id
if err := h.repo.UpdateResearchArea(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update research area: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *HomeHandler) DeleteResearchArea(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid area ID")
return
}
if err := h.repo.DeleteResearchArea(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete research area: "+err.Error())
return
}
httpx.NoContent(c)
}
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
stats, err := h.repo.GetStatsSummary(c.Request.Context())
if err != nil {
+124
View File
@@ -1,11 +1,19 @@
package handlers
import (
"strconv"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/models"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
var validTerms = map[string]bool{
"Spring": true,
"Fall": true,
}
type LecturesHandler struct {
repo *repository.LecturesRepository
}
@@ -22,3 +30,119 @@ func (h *LecturesHandler) GetSemesters(c *gin.Context) {
}
httpx.OK(c, semesters)
}
func (h *LecturesHandler) CreateSemester(c *gin.Context) {
var req models.Semester
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Year < 1970 || !validTerms[req.Term] {
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
return
}
id, err := h.repo.CreateSemester(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create semester: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *LecturesHandler) UpdateSemester(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid semester ID")
return
}
var req models.Semester
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Year < 1970 || !validTerms[req.Term] {
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
return
}
req.ID = id
if err := h.repo.UpdateSemester(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update semester: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *LecturesHandler) DeleteSemester(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid semester ID")
return
}
if err := h.repo.DeleteSemester(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete semester: "+err.Error())
return
}
httpx.NoContent(c)
}
func (h *LecturesHandler) CreateCourse(c *gin.Context) {
semesterID, err := strconv.Atoi(c.Param("semesterId"))
if err != nil || semesterID <= 0 {
httpx.BadRequest(c, "Invalid semesterId parameter")
return
}
var req models.Course
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Code == "" || req.NameEn == "" {
httpx.BadRequest(c, "code and name_en are required")
return
}
req.SemesterID = semesterID
id, err := h.repo.CreateCourse(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create course: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *LecturesHandler) UpdateCourse(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid course ID")
return
}
var req models.Course
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Code == "" || req.NameEn == "" {
httpx.BadRequest(c, "code and name_en are required")
return
}
req.ID = id
if err := h.repo.UpdateCourse(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update course: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *LecturesHandler) DeleteCourse(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid course ID")
return
}
if err := h.repo.DeleteCourse(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete course: "+err.Error())
return
}
httpx.NoContent(c)
}
+143
View File
@@ -1,11 +1,28 @@
package handlers
import (
"strconv"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/models"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
var validMemberDegrees = map[string]bool{
"Professor": true,
"PhD": true,
"PhDCandidate": true,
"PhDStudent": true,
"MSCandidate": true,
"MSStudent": true,
}
var validAlumniDegrees = map[string]bool{
"PhD": true,
"MS": true,
}
type MembersHandler struct {
repo *repository.MembersRepository
}
@@ -23,6 +40,69 @@ func (h *MembersHandler) GetMembers(c *gin.Context) {
httpx.OK(c, members)
}
func (h *MembersHandler) CreateMember(c *gin.Context) {
var req models.Member
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.NameKo == "" || req.NameEn == "" {
httpx.BadRequest(c, "name_ko and name_en are required")
return
}
if !validMemberDegrees[req.Degree] {
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
return
}
id, err := h.repo.CreateMember(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create member: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *MembersHandler) UpdateMember(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid member ID")
return
}
var req models.Member
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.NameKo == "" || req.NameEn == "" {
httpx.BadRequest(c, "name_ko and name_en are required")
return
}
if !validMemberDegrees[req.Degree] {
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
return
}
req.ID = id
if err := h.repo.UpdateMember(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update member: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *MembersHandler) DeleteMember(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid member ID")
return
}
if err := h.repo.DeleteMember(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete member: "+err.Error())
return
}
httpx.NoContent(c)
}
func (h *MembersHandler) GetAlumni(c *gin.Context) {
alumni, err := h.repo.GetAlumni(c.Request.Context())
if err != nil {
@@ -31,3 +111,66 @@ func (h *MembersHandler) GetAlumni(c *gin.Context) {
}
httpx.OK(c, alumni)
}
func (h *MembersHandler) CreateAlumnus(c *gin.Context) {
var req models.Alumnus
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
return
}
if !validAlumniDegrees[req.Degree] {
httpx.BadRequest(c, "degree must be one of: PhD, MS")
return
}
id, err := h.repo.CreateAlumnus(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create alumnus: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *MembersHandler) UpdateAlumnus(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid alumnus ID")
return
}
var req models.Alumnus
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
return
}
if !validAlumniDegrees[req.Degree] {
httpx.BadRequest(c, "degree must be one of: PhD, MS")
return
}
req.ID = id
if err := h.repo.UpdateAlumnus(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update alumnus: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *MembersHandler) DeleteAlumnus(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid alumnus ID")
return
}
if err := h.repo.DeleteAlumnus(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete alumnus: "+err.Error())
return
}
httpx.NoContent(c)
}
+133 -7
View File
@@ -1,11 +1,19 @@
package handlers
import (
"strconv"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/models"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
var validPubCategories = map[string]bool{
"intl-journal-conf": true,
"domestic-journal-conf": true,
}
type PublicationsHandler struct {
repo *repository.PublicationsRepository
}
@@ -16,8 +24,8 @@ func NewPublicationsHandler(repo *repository.PublicationsRepository) *Publicatio
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
category := c.Query("category")
if category != "" && category != "intl-journal-conf" && category != "domestic-journal-conf" {
httpx.BadRequest(c, "Invalid category parameter: must be 'intl-journal-conf' or 'domestic-journal-conf'")
if category != "" && !validPubCategories[category] {
httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'")
return
}
@@ -29,13 +37,76 @@ func (h *PublicationsHandler) GetPublications(c *gin.Context) {
httpx.OK(c, pubs)
}
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
highlights, err := h.repo.GetHighlights(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve highlights: "+err.Error())
func (h *PublicationsHandler) CreatePublication(c *gin.Context) {
var req models.Publication
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
httpx.OK(c, highlights)
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
httpx.BadRequest(c, "title, venue, and published_at are required")
return
}
if !validPubCategories[req.Category] {
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
return
}
id, err := h.repo.CreatePublication(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create publication: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *PublicationsHandler) UpdatePublication(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid publication ID")
return
}
var req models.Publication
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
httpx.BadRequest(c, "title, venue, and published_at are required")
return
}
if !validPubCategories[req.Category] {
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
return
}
req.ID = id
if err := h.repo.UpdatePublication(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update publication: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *PublicationsHandler) DeletePublication(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid publication ID")
return
}
if err := h.repo.DeletePublication(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete publication: "+err.Error())
return
}
httpx.NoContent(c)
}
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
pubs, err := h.repo.GetHighlights(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve highlight publications: "+err.Error())
return
}
httpx.OK(c, pubs)
}
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
@@ -46,3 +117,58 @@ func (h *PublicationsHandler) GetPatents(c *gin.Context) {
}
httpx.OK(c, patents)
}
func (h *PublicationsHandler) CreatePatent(c *gin.Context) {
var req models.Patent
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
return
}
id, err := h.repo.CreatePatent(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create patent: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *PublicationsHandler) UpdatePatent(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid patent ID")
return
}
var req models.Patent
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
return
}
req.ID = id
if err := h.repo.UpdatePatent(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update patent: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *PublicationsHandler) DeletePatent(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid patent ID")
return
}
if err := h.repo.DeletePatent(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete patent: "+err.Error())
return
}
httpx.NoContent(c)
}
@@ -1,11 +1,28 @@
package handlers
import (
"strconv"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/models"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
var validScopes = map[string]bool{
"international": true,
"domestic": true,
}
var validDocStatuses = map[string]bool{
"IS": true,
"TS": true,
"TR": true,
"CDV": true,
"WDTR": true,
"InProgress": true,
}
type StandardizationHandler struct {
repo *repository.StandardizationRepository
}
@@ -22,3 +39,135 @@ func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
}
httpx.OK(c, bodies)
}
func (h *StandardizationHandler) CreateStandardsBody(c *gin.Context) {
var req models.StandardsBody
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Org == "" || req.FullName == "" || req.Period == "" {
httpx.BadRequest(c, "org, full_name, and period are required")
return
}
if !validScopes[req.Scope] {
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
return
}
id, err := h.repo.CreateStandardsBody(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create standards body: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *StandardizationHandler) UpdateStandardsBody(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid standards body ID")
return
}
var req models.StandardsBody
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.Org == "" || req.FullName == "" || req.Period == "" {
httpx.BadRequest(c, "org, full_name, and period are required")
return
}
if !validScopes[req.Scope] {
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
return
}
req.ID = id
if err := h.repo.UpdateStandardsBody(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update standards body: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *StandardizationHandler) DeleteStandardsBody(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid standards body ID")
return
}
if err := h.repo.DeleteStandardsBody(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete standards body: "+err.Error())
return
}
httpx.NoContent(c)
}
func (h *StandardizationHandler) CreateStandardDocument(c *gin.Context) {
bodyID, err := strconv.Atoi(c.Param("bodyId"))
if err != nil || bodyID <= 0 {
httpx.BadRequest(c, "Invalid bodyId parameter")
return
}
var req models.StandardDocument
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
return
}
if !validDocStatuses[req.Status] {
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
return
}
req.BodyID = bodyID
id, err := h.repo.CreateStandardDocument(c.Request.Context(), req)
if err != nil {
httpx.InternalServerError(c, "Failed to create standard document: "+err.Error())
return
}
req.ID = id
httpx.Created(c, req)
}
func (h *StandardizationHandler) UpdateStandardDocument(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid document ID")
return
}
var req models.StandardDocument
if err := c.ShouldBindJSON(&req); err != nil {
httpx.BadRequest(c, "Invalid request body: "+err.Error())
return
}
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
return
}
if !validDocStatuses[req.Status] {
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
return
}
req.ID = id
if err := h.repo.UpdateStandardDocument(c.Request.Context(), id, req); err != nil {
httpx.InternalServerError(c, "Failed to update standard document: "+err.Error())
return
}
httpx.OK(c, req)
}
func (h *StandardizationHandler) DeleteStandardDocument(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
httpx.BadRequest(c, "Invalid document ID")
return
}
if err := h.repo.DeleteStandardDocument(c.Request.Context(), id); err != nil {
httpx.InternalServerError(c, "Failed to delete standard document: "+err.Error())
return
}
httpx.NoContent(c)
}
+35
View File
@@ -0,0 +1,35 @@
package httpx
import (
"strings"
"github.com/gin-gonic/gin"
)
// 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 expectedToken == "" {
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
c.Abort()
return
}
auth := c.GetHeader("Authorization")
if auth == "" {
Unauthorized(c, "Missing Authorization header")
c.Abort()
return
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) != expectedToken {
Unauthorized(c, "Invalid admin bearer token")
c.Abort()
return
}
c.Next()
}
}
+14
View File
@@ -32,6 +32,20 @@ func Error(c *gin.Context, status int, msg string) {
})
}
func Created(c *gin.Context, data any) {
c.JSON(http.StatusCreated, SuccessResponse{
Data: data,
})
}
func NoContent(c *gin.Context) {
c.Status(http.StatusNoContent)
}
func Unauthorized(c *gin.Context, msg string) {
Error(c, http.StatusUnauthorized, msg)
}
func BadRequest(c *gin.Context, msg string) {
Error(c, http.StatusBadRequest, msg)
}
+210 -2
View File
@@ -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
@@ -55,6 +55,54 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
return projects, nil
}
func (r *HomeRepository) CreateResearchProject(ctx context.Context, p models.ResearchProject) (int, error) {
kwBytes, _ := json.Marshal(p.Keywords)
if p.Keywords == nil {
kwBytes = []byte("[]")
}
res, err := r.db.ExecContext(ctx, `
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder)
if err != nil {
return 0, fmt.Errorf("insert research project: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *HomeRepository) UpdateResearchProject(ctx context.Context, id int, p models.ResearchProject) error {
kwBytes, _ := json.Marshal(p.Keywords)
if p.Keywords == nil {
kwBytes = []byte("[]")
}
res, err := r.db.ExecContext(ctx, `
UPDATE research_projects
SET slug = ?, title = ?, abstract = ?, keywords = ?, organization = ?, standards_org = ?, period = ?, funder = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder, id)
if err != nil {
return fmt.Errorf("update research project: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *HomeRepository) DeleteResearchProject(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM research_projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete research project: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
query := `
SELECT id, name_en, name_kr, display_order
@@ -67,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 {
@@ -78,6 +126,166 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
return areas, rows.Err()
}
func (r *HomeRepository) normalizeResearchAreasOrder(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `SELECT id FROM research_areas ORDER BY display_order ASC, id ASC`)
if err != nil {
return fmt.Errorf("query areas for normalization: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("scan area id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return err
}
for idx, id := range ids {
newOrder := idx + 1
_, err := tx.ExecContext(ctx, `UPDATE research_areas SET display_order = ? WHERE id = ?`, newOrder, id)
if err != nil {
return fmt.Errorf("update area %d to order %d: %w", id, newOrder, err)
}
}
return nil
}
func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if a.DisplayOrder > 0 {
// Shift existing research areas with display_order >= target order by +1
_, err = tx.ExecContext(ctx, `
UPDATE research_areas
SET display_order = display_order + 1
WHERE display_order >= ?
`, a.DisplayOrder)
if err != nil {
return 0, fmt.Errorf("shift research areas display_order: %w", err)
}
} else {
var maxOrder sql.NullInt64
_ = tx.QueryRowContext(ctx, `SELECT MAX(display_order) FROM research_areas`).Scan(&maxOrder)
if maxOrder.Valid {
a.DisplayOrder = int(maxOrder.Int64) + 1
} else {
a.DisplayOrder = 1
}
}
res, err := tx.ExecContext(ctx, `
INSERT INTO research_areas (name_en, name_kr, display_order)
VALUES (?, ?, ?)
`, a.NameEn, a.NameKr, a.DisplayOrder)
if err != nil {
return 0, fmt.Errorf("insert research area: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("get inserted area id: %w", err)
}
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
return 0, fmt.Errorf("normalize display_order: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit tx: %w", err)
}
return int(id), nil
}
func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var oldOrder int
err = tx.QueryRowContext(ctx, `SELECT display_order FROM research_areas WHERE id = ?`, id).Scan(&oldOrder)
if err != nil {
if err == sql.ErrNoRows {
return sql.ErrNoRows
}
return fmt.Errorf("get old display_order: %w", err)
}
if a.DisplayOrder > 0 && a.DisplayOrder != oldOrder {
if oldOrder < a.DisplayOrder {
_, err = tx.ExecContext(ctx, `
UPDATE research_areas
SET display_order = display_order - 1
WHERE display_order > ? AND display_order <= ? AND id != ?
`, oldOrder, a.DisplayOrder, id)
} else {
_, err = tx.ExecContext(ctx, `
UPDATE research_areas
SET display_order = display_order + 1
WHERE display_order >= ? AND display_order < ? AND id != ?
`, a.DisplayOrder, oldOrder, id)
}
if err != nil {
return fmt.Errorf("shift research areas display_order on update: %w", err)
}
} else if a.DisplayOrder <= 0 {
a.DisplayOrder = oldOrder
}
res, err := tx.ExecContext(ctx, `
UPDATE research_areas
SET name_en = ?, name_kr = ?, display_order = ?
WHERE id = ?
`, a.NameEn, a.NameKr, a.DisplayOrder, id)
if err != nil {
return fmt.Errorf("update research area: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
return fmt.Errorf("normalize display_order on update: %w", err)
}
return tx.Commit()
}
func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete research area: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
return fmt.Errorf("normalize display_order on delete: %w", err)
}
return tx.Commit()
}
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
var summary models.StatsSummary
+82 -4
View File
@@ -17,7 +17,6 @@ func NewLecturesRepository(db *sql.DB) *LecturesRepository {
}
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
// Query semesters ordered by year DESC and Fall before Spring within same year
semQuery := `
SELECT id, year, term
FROM semesters
@@ -29,8 +28,8 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
}
defer semRows.Close()
var semesters []models.SemesterWithCourses
semMap := make(map[int]int) // semester_id -> index in slice
semesters := make([]models.SemesterWithCourses, 0)
semMap := make(map[int]int)
for semRows.Next() {
var s models.SemesterWithCourses
@@ -45,7 +44,6 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
return nil, err
}
// Query courses ordered by display_order ASC, id ASC
courseQuery := `
SELECT id, semester_id, code, name_en, name_kr, note, display_order
FROM courses
@@ -69,3 +67,83 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
return semesters, courseRows.Err()
}
func (r *LecturesRepository) CreateSemester(ctx context.Context, s models.Semester) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO semesters (year, term)
VALUES (?, ?)
`, s.Year, s.Term)
if err != nil {
return 0, fmt.Errorf("insert semester: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *LecturesRepository) UpdateSemester(ctx context.Context, id int, s models.Semester) error {
res, err := r.db.ExecContext(ctx, `
UPDATE semesters
SET year = ?, term = ?
WHERE id = ?
`, s.Year, s.Term, id)
if err != nil {
return fmt.Errorf("update semester: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *LecturesRepository) DeleteSemester(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM semesters WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete semester: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *LecturesRepository) CreateCourse(ctx context.Context, c models.Course) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
VALUES (?, ?, ?, ?, ?, ?)
`, c.SemesterID, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder)
if err != nil {
return 0, fmt.Errorf("insert course: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *LecturesRepository) UpdateCourse(ctx context.Context, id int, c models.Course) error {
res, err := r.db.ExecContext(ctx, `
UPDATE courses
SET code = ?, name_en = ?, name_kr = ?, note = ?, display_order = ?
WHERE id = ?
`, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder, id)
if err != nil {
return fmt.Errorf("update course: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *LecturesRepository) DeleteCourse(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM courses WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete course: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
+82 -2
View File
@@ -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(
@@ -42,6 +42,46 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
return members, rows.Err()
}
func (r *MembersRepository) CreateMember(ctx context.Context, m models.Member) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
if err != nil {
return 0, fmt.Errorf("insert member: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *MembersRepository) UpdateMember(ctx context.Context, id int, m models.Member) error {
res, err := r.db.ExecContext(ctx, `
UPDATE members
SET name_ko = ?, name_en = ?, degree = ?, affiliation = ?, email = ?, is_advisor = ?, display_order = ?
WHERE id = ?
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder, id)
if err != nil {
return fmt.Errorf("update member: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *MembersRepository) DeleteMember(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM members WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete member: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) {
query := `
SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at
@@ -54,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(
@@ -67,3 +107,43 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
}
return alumni, rows.Err()
}
func (r *MembersRepository) CreateAlumnus(ctx context.Context, a models.Alumnus) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
VALUES (?, ?, ?, ?, ?, ?)
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
if err != nil {
return 0, fmt.Errorf("insert alumnus: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *MembersRepository) UpdateAlumnus(ctx context.Context, id int, a models.Alumnus) error {
res, err := r.db.ExecContext(ctx, `
UPDATE alumni
SET name_ko = ?, name_en = ?, degree = ?, graduated_at = ?, major = ?, current_position = ?
WHERE id = ?
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition, id)
if err != nil {
return fmt.Errorf("update alumnus: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *MembersRepository) DeleteAlumnus(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM alumni WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete alumnus: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
+83 -3
View File
@@ -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(
@@ -48,6 +48,46 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
return pubs, rows.Err()
}
func (r *PublicationsRepository) CreatePublication(ctx context.Context, p models.Publication) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight)
if err != nil {
return 0, fmt.Errorf("insert publication: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *PublicationsRepository) UpdatePublication(ctx context.Context, id int, p models.Publication) error {
res, err := r.db.ExecContext(ctx, `
UPDATE publications
SET category = ?, title = ?, authors = ?, venue = ?, volume = ?, published_at = ?, kci = ?, doi = ?, is_highlight = ?
WHERE id = ?
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight, id)
if err != nil {
return fmt.Errorf("update publication: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *PublicationsRepository) DeletePublication(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM publications WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete publication: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) {
query := `
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
@@ -61,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(
@@ -87,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(
@@ -100,3 +140,43 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
}
return patents, rows.Err()
}
func (r *PublicationsRepository) CreatePatent(ctx context.Context, p models.Patent) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
if err != nil {
return 0, fmt.Errorf("insert patent: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *PublicationsRepository) UpdatePatent(ctx context.Context, id int, p models.Patent) error {
res, err := r.db.ExecContext(ctx, `
UPDATE patents
SET title = ?, inventors = ?, application_no = ?, application_at = ?, registration_no = ?, registration_at = ?, country = ?, published_at = ?
WHERE id = ?
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt, id)
if err != nil {
return fmt.Errorf("update patent: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *PublicationsRepository) DeletePatent(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM patents WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete patent: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
+83 -6
View File
@@ -17,7 +17,6 @@ func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
}
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
// Query bodies ordered by display_order
bodyQuery := `
SELECT id, org, full_name, scope, period, role, display_order
FROM standards_bodies
@@ -29,8 +28,8 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
}
defer bodyRows.Close()
var bodies []models.StandardsBodyWithProjects
bodyMap := make(map[int]int) // body_id -> index in bodies
bodies := make([]models.StandardsBodyWithProjects, 0)
bodyMap := make(map[int]int)
for bodyRows.Next() {
var b models.StandardsBodyWithProjects
@@ -45,7 +44,6 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
return nil, err
}
// Query documents ordered by body_id, and preserves curated insertion order with MIN(id) ASC
docQuery := `
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
FROM standard_documents
@@ -57,13 +55,12 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
}
defer docRows.Close()
// Group documents into projects under each body preserving curated order
type projectKey struct {
bodyID int
wg string
projectName string
}
projectMap := make(map[projectKey]int) // projectKey -> index in bodies[bIdx].Projects
projectMap := make(map[projectKey]int)
for docRows.Next() {
var d models.StandardDocument
@@ -93,3 +90,83 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
return bodies, docRows.Err()
}
func (r *StandardizationRepository) CreateStandardsBody(ctx context.Context, b models.StandardsBody) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
VALUES (?, ?, ?, ?, ?, ?)
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder)
if err != nil {
return 0, fmt.Errorf("insert standards body: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *StandardizationRepository) UpdateStandardsBody(ctx context.Context, id int, b models.StandardsBody) error {
res, err := r.db.ExecContext(ctx, `
UPDATE standards_bodies
SET org = ?, full_name = ?, scope = ?, period = ?, role = ?, display_order = ?
WHERE id = ?
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder, id)
if err != nil {
return fmt.Errorf("update standards body: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *StandardizationRepository) DeleteStandardsBody(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM standards_bodies WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete standards body: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *StandardizationRepository) CreateStandardDocument(ctx context.Context, d models.StandardDocument) (int, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, d.BodyID, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt)
if err != nil {
return 0, fmt.Errorf("insert standard document: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
}
func (r *StandardizationRepository) UpdateStandardDocument(ctx context.Context, id int, d models.StandardDocument) error {
res, err := r.db.ExecContext(ctx, `
UPDATE standard_documents
SET wg = ?, project_name = ?, title = ?, doc_ref = ?, status = ?, published_at = ?
WHERE id = ?
`, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt, id)
if err != nil {
return fmt.Errorf("update standard document: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
func (r *StandardizationRepository) DeleteStandardDocument(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM standard_documents WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete standard document: %w", err)
}
rows, err := res.RowsAffected()
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
}
+109 -2
View File
@@ -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"
@@ -10,7 +13,7 @@ import (
"github.com/gin-gonic/gin"
)
func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine {
r := gin.Default()
allowOrigin := "*"
@@ -44,7 +47,7 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
// API v1 Group
// API v1 Public Group (Read-only)
v1 := r.Group("/api/v1")
{
v1.GET("/health", func(c *gin.Context) {
@@ -72,5 +75,109 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
}
// API v1 Admin Group (Mutating operations requiring Bearer token)
admin := v1.Group("")
admin.Use(httpx.RequireAdminToken(adminToken))
{
admin.GET("/admin/verify", func(c *gin.Context) {
httpx.OK(c, gin.H{"status": "authenticated"})
})
// Research Projects
admin.POST("/research-projects", homeHandler.CreateResearchProject)
admin.PUT("/research-projects/:id", homeHandler.UpdateResearchProject)
admin.DELETE("/research-projects/:id", homeHandler.DeleteResearchProject)
// Research Areas
admin.POST("/research-areas", homeHandler.CreateResearchArea)
admin.PUT("/research-areas/:id", homeHandler.UpdateResearchArea)
admin.DELETE("/research-areas/:id", homeHandler.DeleteResearchArea)
// Members & Alumni
admin.POST("/members", membersHandler.CreateMember)
admin.PUT("/members/:id", membersHandler.UpdateMember)
admin.DELETE("/members/:id", membersHandler.DeleteMember)
admin.POST("/alumni", membersHandler.CreateAlumnus)
admin.PUT("/alumni/:id", membersHandler.UpdateAlumnus)
admin.DELETE("/alumni/:id", membersHandler.DeleteAlumnus)
// Publications & Patents
admin.POST("/publications", pubsHandler.CreatePublication)
admin.PUT("/publications/:id", pubsHandler.UpdatePublication)
admin.DELETE("/publications/:id", pubsHandler.DeletePublication)
admin.POST("/patents", pubsHandler.CreatePatent)
admin.PUT("/patents/:id", pubsHandler.UpdatePatent)
admin.DELETE("/patents/:id", pubsHandler.DeletePatent)
// Lectures (Semesters & Courses)
admin.POST("/semesters", lecturesHandler.CreateSemester)
admin.PUT("/semesters/:id", lecturesHandler.UpdateSemester)
admin.DELETE("/semesters/:id", lecturesHandler.DeleteSemester)
admin.POST("/semesters/:semesterId/courses", lecturesHandler.CreateCourse)
admin.PUT("/courses/:id", lecturesHandler.UpdateCourse)
admin.DELETE("/courses/:id", lecturesHandler.DeleteCourse)
// Standardization (Bodies & Documents)
admin.POST("/standards-bodies", standardsHandler.CreateStandardsBody)
admin.PUT("/standards-bodies/:id", standardsHandler.UpdateStandardsBody)
admin.DELETE("/standards-bodies/:id", standardsHandler.DeleteStandardsBody)
admin.POST("/standards-bodies/:bodyId/documents", standardsHandler.CreateStandardDocument)
admin.PUT("/standard-documents/:id", standardsHandler.UpdateStandardDocument)
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
}
+367 -246
View File
@@ -1,6 +1,7 @@
package router_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -8,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"git.godopu.com/lab/landing_page/backend/internal/db"
@@ -18,8 +20,9 @@ import (
_ "modernc.org/sqlite"
)
const testAdminToken = "test-secret-token"
func setupTestDB(t *testing.T) *sql.DB {
// In-memory or temporary file sqlite DB
tmpDB := filepath.Join(t.TempDir(), "test.db")
database, err := db.Connect(tmpDB)
require.NoError(t, err)
@@ -27,7 +30,6 @@ func setupTestDB(t *testing.T) *sql.DB {
err = db.RunMigrations(database)
require.NoError(t, err)
// Seed test data from ../../seed/data
seedDir := filepath.Join("..", "..", "seed", "data")
seedDatabase(t, database, seedDir)
@@ -65,45 +67,75 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
// 2. Areas
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
require.NoError(t, err)
var areas []models.ResearchArea
var areas []struct {
En string `json:"en"`
Kr *string `json:"kr"`
}
require.NoError(t, json.Unmarshal(aData, &areas))
for _, a := range areas {
_, err := tx.Exec(`INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?)`, a.NameEn, a.NameKr, a.DisplayOrder)
for idx, a := range areas {
_, err := tx.Exec(`
INSERT INTO research_areas (name_en, name_kr, display_order)
VALUES (?, ?, ?)
`, a.En, a.Kr, idx+1)
require.NoError(t, err)
}
// 3. Members
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
require.NoError(t, err)
var members []models.Member
var members []struct {
Ko string `json:"ko"`
En string `json:"en"`
Degree string `json:"degree"`
Affiliation *string `json:"affiliation"`
Email *string `json:"email"`
IsAdvisor bool `json:"is_advisor"`
}
require.NoError(t, json.Unmarshal(mData, &members))
for _, m := range members {
for idx, m := range members {
_, err := tx.Exec(`
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
`, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1)
require.NoError(t, err)
}
// 4. Alumni
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
require.NoError(t, err)
var alumni []models.Alumnus
var alumni []struct {
Ko string `json:"ko"`
En string `json:"en"`
Degree string `json:"degree"`
GraduatedAt string `json:"graduatedAt"`
Major *string `json:"major"`
CurrentPosition *string `json:"currentPosition"`
}
require.NoError(t, json.Unmarshal(alumniData, &alumni))
for _, a := range alumni {
_, err := tx.Exec(`
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
VALUES (?, ?, ?, ?, ?, ?)
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
`, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
require.NoError(t, err)
}
// 5. Publications
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
require.NoError(t, err)
var pubs []models.Publication
require.NoError(t, json.Unmarshal(pubData, &pubs))
for _, p := range pubs {
var publications []struct {
Category string `json:"category"`
Title string `json:"title"`
Authors *string `json:"authors"`
Venue string `json:"venue"`
Volume *string `json:"volume"`
PublishedAt string `json:"publishedAt"`
KCI bool `json:"kci"`
DOI *string `json:"doi"`
IsHighlight bool `json:"is_highlight"`
}
require.NoError(t, json.Unmarshal(pubData, &publications))
for _, p := range publications {
_, err := tx.Exec(`
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -114,17 +146,26 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
// 6. Patents
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
require.NoError(t, err)
var patents []models.Patent
var patents []struct {
Title string `json:"title"`
Inventors string `json:"inventors"`
ApplicationNo string `json:"applicationNo"`
ApplicationAt string `json:"applicationAt"`
RegistrationNo *string `json:"registrationNo"`
RegistrationAt *string `json:"registrationAt"`
Country string `json:"country"`
PublishedAt string `json:"publishedAt"`
}
require.NoError(t, json.Unmarshal(patData, &patents))
for _, pat := range patents {
for _, p := range patents {
_, err := tx.Exec(`
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, pat.Title, pat.Inventors, pat.ApplicationNo, pat.ApplicationAt, pat.RegistrationNo, pat.RegistrationAt, pat.Country, pat.PublishedAt)
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
require.NoError(t, err)
}
// 7. Semesters & Courses
// 7. Lectures
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
require.NoError(t, err)
var semesters []struct {
@@ -200,247 +241,327 @@ type APIResponse[T any] struct {
} `json:"error"`
}
func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
func ptrString(s string) *string {
return &s
}
func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder {
var reqBody []byte
if body != nil {
reqBody, _ = json.Marshal(body)
}
req := httptest.NewRequest(method, target, bytes.NewReader(reqBody))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestHealthEndpoint(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
w := executeRequest(r, "GET", "/api/v1/health")
func TestHealthCheck(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
w := doRequest(r, "GET", "/api/v1/health", nil, "")
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[map[string]any]
err := json.Unmarshal(w.Body.Bytes(), &resp)
}
func TestAdminAuthMiddleware(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// A. Configured with explicit valid token
r := router.SetupRouter(db, testAdminToken)
// 1. Unauthenticated request to /admin/verify should return 401
wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "")
assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code)
// 2. Guessable fallback token "admin123" must return 401
wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123")
assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code)
// 3. Wrong token request to /admin/verify should return 401
wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token")
assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code)
// 4. Valid token request to /admin/verify should return 200
wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken)
assert.Equal(t, http.StatusOK, wValidAuth.Code)
// 5. Public GET routes remain accessible without auth
wPublic := doRequest(r, "GET", "/api/v1/members", nil, "")
assert.Equal(t, http.StatusOK, wPublic.Code)
// B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts
rEmptyToken := router.SetupRouter(db, "")
// 6. Any token on verify should return 401
wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123")
assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code)
wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "")
assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code)
// 7. Mutating POST on empty router should return 401
newProj := models.ResearchProject{
Slug: "test-unauth",
Title: "Unauthorized Title",
Abstract: "Unauthorized abstract.",
Keywords: []string{"unauth"},
}
wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123")
assert.Equal(t, http.StatusUnauthorized, wMutate.Code)
// 8. Public GET routes still work on empty admin token router
wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "")
assert.Equal(t, http.StatusOK, wPublicEmpty.Code)
}
func TestResearchProjectsCRUD(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
// 1. Create Project
newProj := models.ResearchProject{
Slug: "test-crud-proj",
Title: "Test Project Title",
Abstract: "Test project abstract for CRUD verification.",
Keywords: []string{"test", "crud"},
}
wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken)
assert.Equal(t, http.StatusCreated, wCreate.Code)
var createResp APIResponse[models.ResearchProject]
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
createdID := createResp.Data.ID
assert.True(t, createdID > 0)
// 2. Update Project
newProj.Title = "Updated Project Title"
wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken)
assert.Equal(t, http.StatusOK, wUpdate.Code)
// 3. Delete Project
wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken)
assert.Equal(t, http.StatusNoContent, wDelete.Code)
}
func TestResearchAreasDisplayOrderShift(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
// 1. Fetch initial research areas
wInit := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
assert.Equal(t, http.StatusOK, wInit.Code)
var initResp APIResponse[[]models.ResearchArea]
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
initAreas := initResp.Data
initCount := len(initAreas)
require.NotEmpty(t, initAreas)
// Verify initial areas are contiguous 1..N
for idx, a := range initAreas {
assert.Equal(t, idx+1, a.DisplayOrder)
}
// 2. Create at target display_order = 2 (should shift existing 2..N to 3..N+1 and normalize 1..N+1)
newArea := models.ResearchArea{
NameEn: "Quantum Internet Protocols",
NameKr: ptrString("양자 인터넷 프로토콜"),
DisplayOrder: 2,
}
wCreate := doRequest(r, "POST", "/api/v1/research-areas", newArea, testAdminToken)
assert.Equal(t, http.StatusCreated, wCreate.Code)
var createResp APIResponse[models.ResearchArea]
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
createdID := createResp.Data.ID
assert.Equal(t, 2, createResp.Data.DisplayOrder)
// Fetch updated list and assert display_order shifted correctly and gapless
wAfterCreate := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
assert.Equal(t, http.StatusOK, wAfterCreate.Code)
var afterCreateResp APIResponse[[]models.ResearchArea]
require.NoError(t, json.Unmarshal(wAfterCreate.Body.Bytes(), &afterCreateResp))
afterCreateAreas := afterCreateResp.Data
assert.Equal(t, initCount+1, len(afterCreateAreas))
assert.Equal(t, "Quantum Internet Protocols", afterCreateAreas[1].NameEn)
for idx, a := range afterCreateAreas {
assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have display_order %d", idx, idx+1)
}
// 3. Update display_order of the created item from position 2 to position 5
updateArea := models.ResearchArea{
NameEn: "Quantum Internet Protocols Updated",
DisplayOrder: 5,
}
wUpdate := doRequest(r, "PUT", "/api/v1/research-areas/"+strconvItoa(createdID), updateArea, testAdminToken)
assert.Equal(t, http.StatusOK, wUpdate.Code)
wAfterUpdate := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
var afterUpdateResp APIResponse[[]models.ResearchArea]
require.NoError(t, json.Unmarshal(wAfterUpdate.Body.Bytes(), &afterUpdateResp))
afterUpdateAreas := afterUpdateResp.Data
assert.Equal(t, initCount+1, len(afterUpdateAreas))
assert.Equal(t, "Quantum Internet Protocols Updated", afterUpdateAreas[4].NameEn)
assert.Equal(t, 5, afterUpdateAreas[4].DisplayOrder)
for idx, a := range afterUpdateAreas {
assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have contiguous display_order %d after update", idx, idx+1)
}
// 4. Delete the item and verify gapless 1..N normalization back to initCount
wDel := doRequest(r, "DELETE", "/api/v1/research-areas/"+strconvItoa(createdID), nil, testAdminToken)
assert.Equal(t, http.StatusNoContent, wDel.Code)
wAfterDel := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
var afterDelResp APIResponse[[]models.ResearchArea]
require.NoError(t, json.Unmarshal(wAfterDel.Body.Bytes(), &afterDelResp))
afterDelAreas := afterDelResp.Data
assert.Equal(t, initCount, len(afterDelAreas))
for idx, a := range afterDelAreas {
assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have contiguous display_order %d after delete", idx, idx+1)
}
}
func TestMembersAndAlumniCRUD(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
// 1. Create Member with Invalid Degree -> 400
badMember := models.Member{
NameKo: "홍길동",
NameEn: "Gildong Hong",
Degree: "InvalidDegree",
}
wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken)
assert.Equal(t, http.StatusBadRequest, wBad.Code)
// 2. Create Valid Member -> 201
validMember := models.Member{
NameKo: "홍길동",
NameEn: "Gildong Hong",
Degree: "PhDStudent",
}
wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken)
assert.Equal(t, http.StatusCreated, wCreate.Code)
var createResp APIResponse[models.Member]
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
mID := createResp.Data.ID
// 3. Delete Member -> 204
wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken)
assert.Equal(t, http.StatusNoContent, wDelete.Code)
// 4. Create Alumnus -> 201
validAlumnus := models.Alumnus{
NameKo: "김졸업",
NameEn: "Grad Kim",
Degree: "MS",
GraduatedAt: "2024-02",
}
wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken)
assert.Equal(t, http.StatusCreated, wCreateAl.Code)
var alResp APIResponse[models.Alumnus]
require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp))
alID := alResp.Data.ID
// 5. Delete Alumnus -> 204
wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken)
assert.Equal(t, http.StatusNoContent, wDelAl.Code)
}
func TestCascadeDeletes(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
// 1. Create a semester
newSem := models.Semester{Year: 2030, Term: "Spring"}
wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken)
assert.Equal(t, http.StatusCreated, wSem.Code)
var semResp APIResponse[models.Semester]
require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp))
semID := semResp.Data.ID
// 2. Create courses under this semester
course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"}
course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"}
wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken)
assert.Equal(t, http.StatusCreated, wCourse1.Code)
wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken)
assert.Equal(t, http.StatusCreated, wCourse2.Code)
// Verify courses exist in DB
var courseCount int
err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
require.NoError(t, err)
assert.Equal(t, "ok", resp.Data["status"])
assert.Equal(t, "anl-backend-api", resp.Data["service"])
assert.Equal(t, 2, courseCount)
// 3. Delete semester
wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken)
assert.Equal(t, http.StatusNoContent, wDelSem.Code)
// 4. Verify cascade delete removed courses
err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
require.NoError(t, err)
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
}
func TestResearchProjects(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
func TestStaticServingAndRedirects(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
r := router.SetupRouter(database)
w := executeRequest(r, "GET", "/api/v1/research-projects")
// 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"))
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[[]models.ResearchProject]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(t, resp.Data, 3)
assert.Equal(t, "iot-standards", resp.Data[0].Slug)
assert.Equal(t, "AIoT & AI-RAN", resp.Data[0].Title)
// 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 TestResearchAreas(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
w := executeRequest(r, "GET", "/api/v1/research-areas")
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[[]models.ResearchArea]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(t, resp.Data, 14)
assert.Equal(t, 1, resp.Data[0].DisplayOrder)
assert.Equal(t, "Quick UDP Internet Connection (QUIC)", resp.Data[0].NameEn)
}
func TestStatsSummary(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
w := executeRequest(r, "GET", "/api/v1/stats/summary")
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[models.StatsSummary]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 68, resp.Data.IntlPublications)
assert.Equal(t, 44, resp.Data.StandardizationDocs)
assert.Equal(t, 75, resp.Data.Patents)
}
func TestMembersAndAlumni(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
// Members
wMem := executeRequest(r, "GET", "/api/v1/members")
assert.Equal(t, http.StatusOK, wMem.Code)
var respMem APIResponse[[]models.Member]
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &respMem))
assert.Len(t, respMem.Data, 16)
assert.True(t, respMem.Data[0].IsAdvisor)
assert.Equal(t, "Seok-Joo Koh", respMem.Data[0].NameEn)
// Alumni
wAlumni := executeRequest(r, "GET", "/api/v1/alumni")
assert.Equal(t, http.StatusOK, wAlumni.Code)
var respAlumni APIResponse[[]models.Alumnus]
require.NoError(t, json.Unmarshal(wAlumni.Body.Bytes(), &respAlumni))
assert.Len(t, respAlumni.Data, 60)
assert.GreaterOrEqual(t, respAlumni.Data[0].GraduatedAt, respAlumni.Data[1].GraduatedAt)
}
func TestPublicationsAndHighlights(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
// Full Publications
wAll := executeRequest(r, "GET", "/api/v1/publications")
assert.Equal(t, http.StatusOK, wAll.Code)
var respAll APIResponse[[]models.Publication]
require.NoError(t, json.Unmarshal(wAll.Body.Bytes(), &respAll))
assert.Len(t, respAll.Data, 148)
// Category filter: intl
wIntl := executeRequest(r, "GET", "/api/v1/publications?category=intl-journal-conf")
assert.Equal(t, http.StatusOK, wIntl.Code)
var respIntl APIResponse[[]models.Publication]
require.NoError(t, json.Unmarshal(wIntl.Body.Bytes(), &respIntl))
assert.Len(t, respIntl.Data, 68)
// Category filter: domestic
wDom := executeRequest(r, "GET", "/api/v1/publications?category=domestic-journal-conf")
assert.Equal(t, http.StatusOK, wDom.Code)
var respDom APIResponse[[]models.Publication]
require.NoError(t, json.Unmarshal(wDom.Body.Bytes(), &respDom))
assert.Len(t, respDom.Data, 80)
// Invalid category
wInvalid := executeRequest(r, "GET", "/api/v1/publications?category=unknown")
assert.Equal(t, http.StatusBadRequest, wInvalid.Code)
// Highlights (asserts exactly the 5 known representative titles)
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
assert.Equal(t, http.StatusOK, wHL.Code)
var respHL APIResponse[[]models.Publication]
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &respHL))
assert.Len(t, respHL.Data, 5)
expectedTitles := map[string]bool{
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G": true,
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G": true,
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks": true,
"Use of QUIC for CoAP Transport in IoT Networks": true,
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)": true,
}
for _, hl := range respHL.Data {
assert.True(t, hl.IsHighlight)
assert.True(t, expectedTitles[hl.Title], "Unexpected highlight title: %s", hl.Title)
}
// Patents
wPat := executeRequest(r, "GET", "/api/v1/patents")
assert.Equal(t, http.StatusOK, wPat.Code)
var respPat APIResponse[[]models.Patent]
require.NoError(t, json.Unmarshal(wPat.Body.Bytes(), &respPat))
assert.Len(t, respPat.Data, 75)
}
func TestSemestersAndCourses(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
w := executeRequest(r, "GET", "/api/v1/semesters")
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[[]models.SemesterWithCourses]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(t, resp.Data, 45)
// Check semester ordering (Year DESC, Fall before Spring within same year)
first := resp.Data[0]
assert.Equal(t, 2026, first.Year)
assert.Equal(t, "Spring", first.Term)
assert.NotEmpty(t, first.Courses)
// In 2025: Fall must come before Spring
var s2025Fall, s2025Spring int
for idx, s := range resp.Data {
if s.Year == 2025 && s.Term == "Fall" {
s2025Fall = idx
}
if s.Year == 2025 && s.Term == "Spring" {
s2025Spring = idx
}
}
assert.Less(t, s2025Fall, s2025Spring, "2025 Fall should precede 2025 Spring in chronological descending order")
}
func TestStandardsBodiesAndHierarchy(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
w := executeRequest(r, "GET", "/api/v1/standards-bodies")
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[[]models.StandardsBodyWithProjects]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(t, resp.Data, 6)
// First body: IEC TC100
iec := resp.Data[0]
assert.Equal(t, "IEC TC100", iec.Org)
require.NotEmpty(t, iec.Projects)
// Verify project ordering within IEC TC100 (deterministic order: WG12 before WG11(Convenor))
assert.Equal(t, "WG12", iec.Projects[0].WG)
assert.Equal(t, "WG11(Convenor)", iec.Projects[1].WG)
assert.NotEmpty(t, iec.Projects[0].Documents)
}
func TestJSONKeyCasingSnakeCase(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
r := router.SetupRouter(database)
// Test Members endpoint raw JSON map
wMem := executeRequest(r, "GET", "/api/v1/members")
assert.Equal(t, http.StatusOK, wMem.Code)
var rawMem map[string]any
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &rawMem))
dataList, ok := rawMem["data"].([]any)
require.True(t, ok)
require.NotEmpty(t, dataList)
firstMember, ok := dataList[0].(map[string]any)
require.True(t, ok)
// Verify snake_case keys exist and camelCase keys do not
assert.Contains(t, firstMember, "name_ko")
assert.Contains(t, firstMember, "name_en")
assert.Contains(t, firstMember, "is_advisor")
assert.Contains(t, firstMember, "display_order")
assert.NotContains(t, firstMember, "nameKo")
assert.NotContains(t, firstMember, "isAdvisor")
// Test Highlights endpoint raw JSON map
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
assert.Equal(t, http.StatusOK, wHL.Code)
var rawHL map[string]any
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &rawHL))
hlList, ok := rawHL["data"].([]any)
require.True(t, ok)
require.NotEmpty(t, hlList)
firstHL, ok := hlList[0].(map[string]any)
require.True(t, ok)
assert.Contains(t, firstHL, "is_highlight")
assert.Contains(t, firstHL, "published_at")
assert.NotContains(t, firstHL, "isHighlight")
assert.NotContains(t, firstHL, "publishedAt")
func strconvItoa(i int) string {
return strconv.Itoa(i)
}
+12 -6
View File
@@ -1,12 +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:
- "8080:8080"
- "${APP_PORT:-8080}:8080"
environment:
- HOST=0.0.0.0
- PORT=8080
@@ -14,13 +18,15 @@ services:
- GIN_MODE=release
- CORS_ALLOW_ORIGINS=*
- AUTO_MIGRATE=true
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set -- see .env.example}
volumes:
- anl-db-data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"]
interval: 30s
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
volumes:
anl-db-data:
@@ -0,0 +1,116 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function getClientToken(): string {
if (typeof window === "undefined") return "";
const match = document.cookie.match(new RegExp("(^| )admin_token=([^;]+)"));
if (match) return decodeURIComponent(match[2]);
return localStorage.getItem("admin_token") || "";
}
export function setClientToken(token: string) {
if (typeof window === "undefined") return;
document.cookie = `admin_token=${encodeURIComponent(token)}; path=/; max-age=604800; SameSite=Lax`;
localStorage.setItem("admin_token", token);
}
export function removeClientToken() {
if (typeof window === "undefined") return;
document.cookie = "admin_token=; path=/; max-age=0; SameSite=Lax";
localStorage.removeItem("admin_token");
}
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 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="flex items-center gap-3 shrink-0">{action}</div>}
</div>
);
}
export function AlertBanner({ type, message, onClose }: { type: "error" | "success" | "info"; message: string; onClose?: () => void }) {
const styles = {
error: "bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300",
success: "bg-emerald-50 dark:bg-emerald-950/40 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300",
info: "bg-blue-50 dark:bg-blue-950/40 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300",
};
return (
<div className={`p-4 rounded-md border mb-4 flex items-center justify-between text-sm ${styles[type]}`}>
<div>{message}</div>
{onClose && (
<button onClick={onClose} className="text-xs font-semibold underline ml-4 hover:opacity-75">
Dismiss
</button>
)}
</div>
);
}
export function Modal({ isOpen, title, onClose, children }: { isOpen: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-paper border border-line rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
<h3 className="text-lg font-bold text-ink">{title}</h3>
<button onClick={onClose} className="text-ink/60 hover:text-ink text-xl font-bold">
&times;
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
export function ConfirmDeleteModal({
isOpen,
title = "Confirm Deletion",
description = "Are you sure you want to delete this item? This action cannot be undone.",
onClose,
onConfirm,
isSubmitting,
}: {
isOpen: boolean;
title?: string;
description?: string;
onClose: () => void;
onConfirm: () => void;
isSubmitting?: boolean;
}) {
if (!isOpen) return null;
return (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<div className="space-y-4">
<p className="text-sm text-ink/80">{description}</p>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-line bg-paper hover:bg-ivory text-ink font-medium"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-red-600 hover:bg-red-700 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Deleting..." : "Delete Permanently"}
</button>
</div>
</div>
</Modal>
);
}
@@ -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>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
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();
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
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);
}
}
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">
<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 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" },
{ href: "/console/research-areas", label: "Research Areas" },
{ href: "/console/members", label: "Members & Alumni" },
{ href: "/console/publications", label: "Publications & Patents" },
{ href: "/console/lectures", label: "Lectures & Courses" },
{ href: "/console/standardization", label: "Standardization" },
];
const handleLogout = () => {
removeClientToken();
setIsAuthenticated(false);
};
return (
<div className="min-h-screen bg-ivory text-ink flex flex-col md:flex-row">
{/* 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">
<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>
</div>
<nav className="p-4 space-y-1.5 flex-1 overflow-y-auto">
{navItems.map((item) => {
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-all duration-150 ${
isActive
? "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}
</Link>
);
})}
</nav>
<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:border-cobalt/40 hover:bg-cobalt/10 hover:text-cobalt text-ink/80 transition-colors"
>
&larr; Return to Public Site
</Link>
<button
onClick={handleLogout}
className="block w-full text-center px-3 py-2 text-xs font-medium rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900"
>
Logout
</button>
</div>
</aside>
{/* 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>
);
}
@@ -0,0 +1,473 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetSemesters,
adminCreateSemester,
adminUpdateSemester,
adminDeleteSemester,
adminCreateCourse,
adminUpdateCourse,
adminDeleteCourse,
AdminSemester,
AdminCourse,
} from "../../../lib/adminApi";
export default function AdminLecturesPage() {
const [semesters, setSemesters] = useState<AdminSemester[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Semester Modal State
const [isSemModalOpen, setIsSemModalOpen] = useState(false);
const [editingSemester, setEditingSemester] = useState<AdminSemester | null>(null);
const [semForm, setSemForm] = useState({
year: new Date().getFullYear(),
term: "Spring" as "Spring" | "Fall",
});
// Course Modal State
const [isCourseModalOpen, setIsCourseModalOpen] = useState(false);
const [targetSemesterId, setTargetSemesterId] = useState<number | null>(null);
const [editingCourse, setEditingCourse] = useState<AdminCourse | null>(null);
const [courseForm, setCourseForm] = useState({
code: "",
name_en: "",
name_kr: "",
note: "",
display_order: 0,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingSemId, setDeletingSemId] = useState<number | null>(null);
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
const loadSemesters = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetSemesters();
setSemesters(data);
} catch (err: any) {
setError(err?.message || "Failed to load semesters.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadSemesters();
}, []);
// Semester Actions
const handleOpenSemCreate = () => {
setEditingSemester(null);
setSemForm({
year: new Date().getFullYear(),
term: "Spring",
});
setIsSemModalOpen(true);
};
const handleOpenSemEdit = (s: AdminSemester) => {
setEditingSemester(s);
setSemForm({
year: s.year,
term: s.term,
});
setIsSemModalOpen(true);
};
const handleSemSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
year: Number(semForm.year),
term: semForm.term,
};
setIsSubmitting(true);
setError(null);
try {
if (editingSemester) {
await adminUpdateSemester(token, editingSemester.id, payload);
setSuccessMsg(`Semester ${payload.year} ${payload.term} updated.`);
} else {
await adminCreateSemester(token, payload);
setSuccessMsg(`Semester ${payload.year} ${payload.term} created.`);
}
setIsSemModalOpen(false);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to save semester.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteSem = async () => {
if (!deletingSemId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteSemester(token, deletingSemId);
setSuccessMsg("Semester and all its courses deleted successfully (Cascade).");
setDeletingSemId(null);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to delete semester.");
} finally {
setIsSubmitting(false);
}
};
// Course Actions
const handleOpenCourseCreate = (semesterId: number) => {
setTargetSemesterId(semesterId);
setEditingCourse(null);
const sem = semesters.find((s) => s.id === semesterId);
setCourseForm({
code: "",
name_en: "",
name_kr: "",
note: "",
display_order: (sem?.courses?.length || 0) + 1,
});
setIsCourseModalOpen(true);
};
const handleOpenCourseEdit = (c: AdminCourse) => {
setTargetSemesterId(c.semester_id);
setEditingCourse(c);
setCourseForm({
code: c.code,
name_en: c.name_en,
name_kr: c.name_kr || "",
note: c.note || "",
display_order: c.display_order,
});
setIsCourseModalOpen(true);
};
const handleCourseSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token || !targetSemesterId) return;
const payload = {
code: courseForm.code.trim(),
name_en: courseForm.name_en.trim(),
name_kr: courseForm.name_kr.trim() || undefined,
note: courseForm.note.trim() || undefined,
display_order: Number(courseForm.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingCourse) {
await adminUpdateCourse(token, editingCourse.id, payload);
setSuccessMsg(`Course "${payload.name_en}" updated.`);
} else {
await adminCreateCourse(token, targetSemesterId, payload);
setSuccessMsg(`Course "${payload.name_en}" added.`);
}
setIsCourseModalOpen(false);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to save course.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteCourse = async () => {
if (!deletingCourseId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteCourse(token, deletingCourseId);
setSuccessMsg("Course deleted successfully.");
setDeletingCourseId(null);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to delete course.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Lectures & Courses Management"
description="Manage lecture history by academic semesters and undergraduate/graduate courses."
action={
<button
onClick={handleOpenSemCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add New Semester
</button>
}
/>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading semesters and courses...</div>
) : semesters.length === 0 ? (
<div className="bg-paper border border-line rounded-lg p-12 text-center text-ink/60">
No lecture semesters recorded. Click &quot;+ Add New Semester&quot; to get started.
</div>
) : (
<div className="space-y-6">
{semesters.map((sem) => (
<div key={sem.id} className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
{/* Semester Header Bar */}
<div className="px-6 py-4 bg-ivory border-b border-line flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-bold text-base text-ink">
{sem.year} {sem.term} Semester
</span>
<span className="text-xs bg-cobalt/10 text-cobalt px-2 py-0.5 rounded font-medium">
{sem.courses?.length || 0} Courses
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenCourseCreate(sem.id)}
className="px-2.5 py-1 text-xs rounded bg-cobalt text-white font-medium hover:bg-cobalt/90"
>
+ Add Course
</button>
<button
onClick={() => handleOpenSemEdit(sem)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingSemId(sem.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete Semester
</button>
</div>
</div>
{/* Courses Table */}
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="border-b border-line text-xs font-semibold text-ink/60 bg-paper">
<th className="p-3 w-16">Order</th>
<th className="p-3 w-32">Course Code</th>
<th className="p-3">Course Name (En / Kr)</th>
<th className="p-3">Note</th>
<th className="p-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{!sem.courses || sem.courses.length === 0 ? (
<tr>
<td colSpan={5} className="p-4 text-center text-xs text-ink/50">
No courses added for this semester.
</td>
</tr>
) : (
sem.courses.map((c) => (
<tr key={c.id} className="hover:bg-ivory/40 text-xs">
<td className="p-3 font-mono text-ink/60">{c.display_order}</td>
<td className="p-3 font-mono font-medium text-cobalt">{c.code}</td>
<td className="p-3 font-medium text-ink">
{c.name_en} {c.name_kr && <span className="text-ink/60 font-normal">({c.name_kr})</span>}
</td>
<td className="p-3 text-ink/60">{c.note || "—"}</td>
<td className="p-3 text-right space-x-2">
<button
onClick={() => handleOpenCourseEdit(c)}
className="px-2 py-0.5 rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingCourseId(c.id)}
className="px-2 py-0.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
))}
</div>
)}
{/* Semester Modal */}
<Modal
isOpen={isSemModalOpen}
title={editingSemester ? "Edit Semester" : "Create Semester"}
onClose={() => setIsSemModalOpen(false)}
>
<form onSubmit={handleSemSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Academic Year *</label>
<input
type="number"
value={semForm.year}
onChange={(e) => setSemForm({ ...semForm, year: Number(e.target.value) })}
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Term *</label>
<select
value={semForm.term}
onChange={(e) => setSemForm({ ...semForm, term: e.target.value as "Spring" | "Fall" })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
<option value="Spring">Spring</option>
<option value="Fall">Fall</option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsSemModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingSemester ? "Save Changes" : "Create Semester"}
</button>
</div>
</form>
</Modal>
{/* Course Modal */}
<Modal
isOpen={isCourseModalOpen}
title={editingCourse ? "Edit Course" : "Add New Course"}
onClose={() => setIsCourseModalOpen(false)}
>
<form onSubmit={handleCourseSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Course Code *</label>
<input
type="text"
value={courseForm.code}
onChange={(e) => setCourseForm({ ...courseForm, code: e.target.value })}
placeholder="e.g. ITNT683"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
<input
type="number"
value={courseForm.display_order}
onChange={(e) => setCourseForm({ ...courseForm, display_order: Number(e.target.value) })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Course Name (English) *</label>
<input
type="text"
value={courseForm.name_en}
onChange={(e) => setCourseForm({ ...courseForm, name_en: e.target.value })}
placeholder="e.g. Digital Signal Processing"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Course Name (Korean optional)</label>
<input
type="text"
value={courseForm.name_kr}
onChange={(e) => setCourseForm({ ...courseForm, name_kr: e.target.value })}
placeholder="e.g. 디지털신호처리"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Note (e.g. Graduate/Undergraduate)</label>
<input
type="text"
value={courseForm.note}
onChange={(e) => setCourseForm({ ...courseForm, note: e.target.value })}
placeholder="e.g. Graduate"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsCourseModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingCourse ? "Save Changes" : "Add Course"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingSemId !== null}
onClose={() => setDeletingSemId(null)}
onConfirm={handleConfirmDeleteSem}
isSubmitting={isSubmitting}
title="Delete Semester (Cascade Warning)"
description="Are you sure you want to delete this semester? All courses under this semester will also be permanently deleted (Database Cascade)."
/>
<ConfirmDeleteModal
isOpen={deletingCourseId !== null}
onClose={() => setDeletingCourseId(null)}
onConfirm={handleConfirmDeleteCourse}
isSubmitting={isSubmitting}
title="Delete Course"
description="Are you sure you want to delete this course?"
/>
</div>
);
}
@@ -0,0 +1,8 @@
"use client";
import React from "react";
import { AdminLoginForm } from "../_components/AdminLoginForm";
export default function AdminLoginPage() {
return <AdminLoginForm />;
}
@@ -0,0 +1,623 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetMembers,
adminCreateMember,
adminUpdateMember,
adminDeleteMember,
adminGetAlumni,
adminCreateAlumnus,
adminUpdateAlumnus,
adminDeleteAlumnus,
AdminMember,
AdminAlumnus,
} from "../../../lib/adminApi";
const MEMBER_DEGREES = [
"Professor",
"PhD",
"PhDCandidate",
"PhDStudent",
"MSCandidate",
"MSStudent",
];
const ALUMNI_DEGREES = ["PhD", "MS"];
export default function AdminMembersPage() {
const [tab, setTab] = useState<"active" | "alumni">("active");
const [members, setMembers] = useState<AdminMember[]>([]);
const [alumni, setAlumni] = useState<AdminAlumnus[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Active Member Modal State
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false);
const [editingMember, setEditingMember] = useState<AdminMember | null>(null);
const [memberForm, setMemberForm] = useState({
name_ko: "",
name_en: "",
degree: "PhDStudent",
affiliation: "",
email: "",
is_advisor: false,
display_order: 0,
});
// Alumni Modal State
const [isAlumniModalOpen, setIsAlumniModalOpen] = useState(false);
const [editingAlumnus, setEditingAlumnus] = useState<AdminAlumnus | null>(null);
const [alumniForm, setAlumniForm] = useState({
name_ko: "",
name_en: "",
degree: "MS",
graduated_at: "",
major: "",
current_position: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingMemberId, setDeletingMemberId] = useState<number | null>(null);
const [deletingAlumniId, setDeletingAlumniId] = useState<number | null>(null);
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
const [mList, aList] = await Promise.all([adminGetMembers(), adminGetAlumni()]);
setMembers(mList);
setAlumni(aList);
} catch (err: any) {
setError(err?.message || "Failed to load members data.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// Member Handlers
const handleOpenMemberCreate = () => {
setEditingMember(null);
setMemberForm({
name_ko: "",
name_en: "",
degree: "PhDStudent",
affiliation: "",
email: "",
is_advisor: false,
display_order: members.length + 1,
});
setIsMemberModalOpen(true);
};
const handleOpenMemberEdit = (m: AdminMember) => {
setEditingMember(m);
setMemberForm({
name_ko: m.name_ko,
name_en: m.name_en,
degree: m.degree,
affiliation: m.affiliation || "",
email: m.email || "",
is_advisor: m.is_advisor,
display_order: m.display_order,
});
setIsMemberModalOpen(true);
};
const handleMemberSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
const payload = {
name_ko: memberForm.name_ko.trim(),
name_en: memberForm.name_en.trim(),
degree: memberForm.degree,
affiliation: memberForm.affiliation.trim() || undefined,
email: memberForm.email.trim() || undefined,
is_advisor: memberForm.is_advisor,
display_order: Number(memberForm.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingMember) {
await adminUpdateMember(token, editingMember.id, payload);
setSuccessMsg(`Member "${payload.name_ko}" updated successfully.`);
} else {
await adminCreateMember(token, payload);
setSuccessMsg(`Member "${payload.name_ko}" created successfully.`);
}
setIsMemberModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save member.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteMember = async () => {
if (!deletingMemberId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteMember(token, deletingMemberId);
setSuccessMsg("Member deleted successfully.");
setDeletingMemberId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete member.");
} finally {
setIsSubmitting(false);
}
};
// Alumni Handlers
const handleOpenAlumniCreate = () => {
setEditingAlumnus(null);
setAlumniForm({
name_ko: "",
name_en: "",
degree: "MS",
graduated_at: "",
major: "",
current_position: "",
});
setIsAlumniModalOpen(true);
};
const handleOpenAlumniEdit = (a: AdminAlumnus) => {
setEditingAlumnus(a);
setAlumniForm({
name_ko: a.name_ko,
name_en: a.name_en,
degree: a.degree,
graduated_at: a.graduated_at,
major: a.major || "",
current_position: a.current_position || "",
});
setIsAlumniModalOpen(true);
};
const handleAlumniSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
name_ko: alumniForm.name_ko.trim(),
name_en: alumniForm.name_en.trim(),
degree: alumniForm.degree,
graduated_at: alumniForm.graduated_at.trim(),
major: alumniForm.major.trim() || undefined,
current_position: alumniForm.current_position.trim() || undefined,
};
setIsSubmitting(true);
setError(null);
try {
if (editingAlumnus) {
await adminUpdateAlumnus(token, editingAlumnus.id, payload);
setSuccessMsg(`Alumnus "${payload.name_ko}" updated successfully.`);
} else {
await adminCreateAlumnus(token, payload);
setSuccessMsg(`Alumnus "${payload.name_ko}" created successfully.`);
}
setIsAlumniModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save alumnus.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteAlumnus = async () => {
if (!deletingAlumniId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteAlumnus(token, deletingAlumniId);
setSuccessMsg("Alumnus deleted successfully.");
setDeletingAlumniId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete alumnus.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Members & Alumni Management"
description="Manage active laboratory researchers, advisors, and graduated alumni."
action={
<button
onClick={tab === "active" ? handleOpenMemberCreate : handleOpenAlumniCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
{tab === "active" ? "+ Add Active Member" : "+ Add Alumnus"}
</button>
}
/>
{/* Tabs */}
<div className="flex border-b border-line mb-6 gap-6">
<button
onClick={() => setTab("active")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "active" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Active Members ({members.length})
</button>
<button
onClick={() => setTab("alumni")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "alumni" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Alumni ({alumni.length})
</button>
</div>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading records...</div>
) : tab === "active" ? (
/* Active Members 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-16">Order</th>
<th className="p-4">Name (Ko / En)</th>
<th className="p-4">Degree</th>
<th className="p-4">Affiliation / Email</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{members.length === 0 ? (
<tr>
<td colSpan={5} className="p-6 text-center text-ink/60">
No active members found.
</td>
</tr>
) : (
members.map((m) => (
<tr key={m.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/60">{m.display_order}</td>
<td className="p-4">
<div className="font-semibold text-ink flex items-center gap-2">
{m.name_ko} ({m.name_en})
{m.is_advisor && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-cobalt/10 text-cobalt font-bold">
Advisor
</span>
)}
</div>
</td>
<td className="p-4 text-xs font-medium text-ink/80">{m.degree}</td>
<td className="p-4 text-xs text-ink/70">
<div>{m.affiliation || "—"}</div>
<div className="text-ink/50">{m.email || "—"}</div>
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenMemberEdit(m)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingMemberId(m.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : (
/* Alumni 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">Graduation Date</th>
<th className="p-4">Name (Ko / En)</th>
<th className="p-4">Degree</th>
<th className="p-4">Major / Current Position</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{alumni.length === 0 ? (
<tr>
<td colSpan={5} className="p-6 text-center text-ink/60">
No alumni found.
</td>
</tr>
) : (
alumni.map((a) => (
<tr key={a.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/80">{a.graduated_at}</td>
<td className="p-4 font-semibold text-ink">
{a.name_ko} ({a.name_en})
</td>
<td className="p-4 text-xs font-medium text-ink/80">{a.degree}</td>
<td className="p-4 text-xs text-ink/70">
<div>{a.current_position || "—"}</div>
<div className="text-ink/50">{a.major || "—"}</div>
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenAlumniEdit(a)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingAlumniId(a.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Member Modal */}
<Modal
isOpen={isMemberModalOpen}
title={editingMember ? "Edit Active Member" : "Create Active Member"}
onClose={() => setIsMemberModalOpen(false)}
>
<form onSubmit={handleMemberSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Korean Name *</label>
<input
type="text"
value={memberForm.name_ko}
onChange={(e) => setMemberForm({ ...memberForm, name_ko: e.target.value })}
placeholder="e.g. 홍길동"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
<input
type="text"
value={memberForm.name_en}
onChange={(e) => setMemberForm({ ...memberForm, name_en: e.target.value })}
placeholder="e.g. Gildong Hong"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Degree Level *</label>
<select
value={memberForm.degree}
onChange={(e) => setMemberForm({ ...memberForm, degree: e.target.value })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
{MEMBER_DEGREES.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
<input
type="number"
value={memberForm.display_order}
onChange={(e) => setMemberForm({ ...memberForm, display_order: Number(e.target.value) })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Affiliation</label>
<input
type="text"
value={memberForm.affiliation}
onChange={(e) => setMemberForm({ ...memberForm, affiliation: e.target.value })}
placeholder="e.g. School of Electronic Engineering"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Email</label>
<input
type="email"
value={memberForm.email}
onChange={(e) => setMemberForm({ ...memberForm, email: e.target.value })}
placeholder="e.g. user@knu.ac.kr"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="is_advisor"
checked={memberForm.is_advisor}
onChange={(e) => setMemberForm({ ...memberForm, is_advisor: e.target.checked })}
className="rounded text-cobalt focus:ring-cobalt"
/>
<label htmlFor="is_advisor" className="text-xs font-semibold text-ink">
Principal Investigator / Lab Advisor
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsMemberModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingMember ? "Save Changes" : "Create Member"}
</button>
</div>
</form>
</Modal>
{/* Alumni Modal */}
<Modal
isOpen={isAlumniModalOpen}
title={editingAlumnus ? "Edit Alumnus" : "Create Alumnus"}
onClose={() => setIsAlumniModalOpen(false)}
>
<form onSubmit={handleAlumniSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Korean Name *</label>
<input
type="text"
value={alumniForm.name_ko}
onChange={(e) => setAlumniForm({ ...alumniForm, name_ko: e.target.value })}
placeholder="e.g. 홍길동"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
<input
type="text"
value={alumniForm.name_en}
onChange={(e) => setAlumniForm({ ...alumniForm, name_en: e.target.value })}
placeholder="e.g. Gildong Hong"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Graduation Date (ISO 8601) *</label>
<input
type="text"
value={alumniForm.graduated_at}
onChange={(e) => setAlumniForm({ ...alumniForm, graduated_at: e.target.value })}
placeholder="e.g. 2024-02"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Degree *</label>
<select
value={alumniForm.degree}
onChange={(e) => setAlumniForm({ ...alumniForm, degree: e.target.value })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
{ALUMNI_DEGREES.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Current Position</label>
<input
type="text"
value={alumniForm.current_position}
onChange={(e) => setAlumniForm({ ...alumniForm, current_position: e.target.value })}
placeholder="e.g. Samsung Electronics, Principal Engineer"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsAlumniModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingAlumnus ? "Save Changes" : "Create Alumnus"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingMemberId !== null}
onClose={() => setDeletingMemberId(null)}
onConfirm={handleConfirmDeleteMember}
isSubmitting={isSubmitting}
title="Delete Member"
description="Are you sure you want to delete this member?"
/>
<ConfirmDeleteModal
isOpen={deletingAlumniId !== null}
onClose={() => setDeletingAlumniId(null)}
onConfirm={handleConfirmDeleteAlumnus}
isSubmitting={isSubmitting}
title="Delete Alumnus"
description="Are you sure you want to delete this alumnus?"
/>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { AdminHeader, AlertBanner } from "./_components/AdminComponents";
import {
adminGetResearchProjects,
adminGetResearchAreas,
adminGetMembers,
adminGetAlumni,
adminGetPublications,
adminGetPatents,
adminGetSemesters,
adminGetStandardsBodies,
} from "../../lib/adminApi";
export default function AdminDashboardPage() {
const [counts, setCounts] = useState<{
projects: number;
areas: number;
members: number;
alumni: number;
intlPubs: number;
domPubs: number;
patents: number;
semesters: number;
courses: number;
bodies: number;
docs: number;
}>({
projects: 0,
areas: 0,
members: 0,
alumni: 0,
intlPubs: 0,
domPubs: 0,
patents: 0,
semesters: 0,
courses: 0,
bodies: 0,
docs: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadData() {
try {
setIsLoading(true);
const [
projects,
areas,
members,
alumni,
intlPubs,
domPubs,
patents,
semesters,
bodies,
] = await Promise.all([
adminGetResearchProjects().catch(() => []),
adminGetResearchAreas().catch(() => []),
adminGetMembers().catch(() => []),
adminGetAlumni().catch(() => []),
adminGetPublications("intl-journal-conf").catch(() => []),
adminGetPublications("domestic-journal-conf").catch(() => []),
adminGetPatents().catch(() => []),
adminGetSemesters().catch(() => []),
adminGetStandardsBodies().catch(() => []),
]);
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 = 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: 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: safeBodies.length,
docs: totalDocs,
});
} catch (err: any) {
setError(err?.message || "Failed to load dashboard data from backend API.");
} finally {
setIsLoading(false);
}
}
loadData();
}, []);
const cards = [
{
title: "Research Projects",
count: counts.projects,
sub: `${counts.areas} Research Areas`,
href: "/console/research-projects",
color: "border-cobalt/30 hover:border-cobalt",
},
{
title: "Members & Alumni",
count: counts.members,
sub: `${counts.alumni} Alumni Records`,
href: "/console/members",
color: "border-emerald-500/30 hover:border-emerald-500",
},
{
title: "Publications & Patents",
count: counts.intlPubs + counts.domPubs,
sub: `${counts.intlPubs} Intl / ${counts.domPubs} Dom / ${counts.patents} Patents`,
href: "/console/publications",
color: "border-blue-500/30 hover:border-blue-500",
},
{
title: "Lectures & Courses",
count: counts.semesters,
sub: `${counts.courses} Total Courses across Semesters`,
href: "/console/lectures",
color: "border-amber-500/30 hover:border-amber-500",
},
{
title: "Standardization",
count: counts.bodies,
sub: `${counts.docs} Standard Documents`,
href: "/console/standardization",
color: "border-purple-500/30 hover:border-purple-500",
},
{
title: "Research Areas",
count: counts.areas,
sub: "Active Laboratory Focus Areas",
href: "/console/research-areas",
color: "border-indigo-500/30 hover:border-indigo-500",
},
];
return (
<div>
<AdminHeader
title="Administrative Overview"
description="Live status of all ANL laboratory records and datasets stored in the backend."
/>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading dashboard statistics...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{cards.map((card) => (
<Link
key={card.title}
href={card.href}
className={`p-6 bg-paper rounded-lg border ${card.color} transition-all shadow-sm flex flex-col justify-between`}
>
<div>
<h2 className="text-base font-semibold text-ink">{card.title}</h2>
<div className="text-3xl font-extrabold text-cobalt mt-3">{card.count}</div>
<p className="text-xs text-ink/70 mt-1">{card.sub}</p>
</div>
<div className="mt-4 pt-4 border-t border-line text-xs font-medium text-cobalt flex items-center justify-between">
<span>Manage Records</span>
<span>&rarr;</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,836 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetPublications,
adminCreatePublication,
adminUpdatePublication,
adminDeletePublication,
adminGetPatents,
adminCreatePatent,
adminUpdatePatent,
adminDeletePatent,
AdminPublication,
AdminPatent,
} from "../../../lib/adminApi";
export default function AdminPublicationsPage() {
const [tab, setTab] = useState<"publications" | "patents">("publications");
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);
// Publication Modal State
const [isPubModalOpen, setIsPubModalOpen] = useState(false);
const [editingPub, setEditingPub] = useState<AdminPublication | null>(null);
const [pubForm, setPubForm] = useState({
category: "intl-journal-conf" as "intl-journal-conf" | "domestic-journal-conf",
title: "",
authors: "",
venue: "",
volume: "",
published_at: "",
kci: false,
doi: "",
is_highlight: false,
});
// Patent Modal State
const [isPatentModalOpen, setIsPatentModalOpen] = useState(false);
const [editingPatent, setEditingPatent] = useState<AdminPatent | null>(null);
const [patentForm, setPatentForm] = useState({
title: "",
inventors: "",
application_no: "",
application_at: "",
registration_no: "",
registration_at: "",
country: "Korea",
published_at: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingPubId, setDeletingPubId] = useState<number | null>(null);
const [deletingPatentId, setDeletingPatentId] = useState<number | null>(null);
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
const [pubList, patList] = await Promise.all([adminGetPublications(), adminGetPatents()]);
setPublications(pubList);
setPatents(patList);
} catch (err: any) {
setError(err?.message || "Failed to load publications.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// Pub Handlers
const handleOpenPubCreate = () => {
setEditingPub(null);
setPubForm({
category: "intl-journal-conf",
title: "",
authors: "",
venue: "",
volume: "",
published_at: new Date().getFullYear().toString(),
kci: false,
doi: "",
is_highlight: false,
});
setIsPubModalOpen(true);
};
const handleOpenPubEdit = (p: AdminPublication) => {
setEditingPub(p);
setPubForm({
category: p.category,
title: p.title,
authors: p.authors || "",
venue: p.venue,
volume: p.volume || "",
published_at: p.published_at,
kci: p.kci,
doi: p.doi || "",
is_highlight: p.is_highlight,
});
setIsPubModalOpen(true);
};
const handlePubSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
category: pubForm.category,
title: pubForm.title.trim(),
authors: pubForm.authors.trim() || undefined,
venue: pubForm.venue.trim(),
volume: pubForm.volume.trim() || undefined,
published_at: pubForm.published_at.trim(),
kci: pubForm.kci,
doi: pubForm.doi.trim() || undefined,
is_highlight: pubForm.is_highlight,
};
setIsSubmitting(true);
setError(null);
try {
if (editingPub) {
await adminUpdatePublication(token, editingPub.id, payload);
setSuccessMsg(`Publication "${payload.title}" updated.`);
} else {
await adminCreatePublication(token, payload);
setSuccessMsg(`Publication "${payload.title}" created.`);
}
setIsPubModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save publication.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeletePub = async () => {
if (!deletingPubId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeletePublication(token, deletingPubId);
setSuccessMsg("Publication deleted successfully.");
setDeletingPubId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete publication.");
} finally {
setIsSubmitting(false);
}
};
// Patent Handlers
const handleOpenPatentCreate = () => {
setEditingPatent(null);
setPatentForm({
title: "",
inventors: "",
application_no: "",
application_at: "",
registration_no: "",
registration_at: "",
country: "Korea",
published_at: new Date().getFullYear().toString(),
});
setIsPatentModalOpen(true);
};
const handleOpenPatentEdit = (p: AdminPatent) => {
setEditingPatent(p);
setPatentForm({
title: p.title,
inventors: p.inventors,
application_no: p.application_no,
application_at: p.application_at,
registration_no: p.registration_no || "",
registration_at: p.registration_at || "",
country: p.country,
published_at: p.published_at,
});
setIsPatentModalOpen(true);
};
const handlePatentSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
title: patentForm.title.trim(),
inventors: patentForm.inventors.trim(),
application_no: patentForm.application_no.trim(),
application_at: patentForm.application_at.trim(),
registration_no: patentForm.registration_no.trim() || undefined,
registration_at: patentForm.registration_at.trim() || undefined,
country: patentForm.country.trim(),
published_at: patentForm.published_at.trim(),
};
setIsSubmitting(true);
setError(null);
try {
if (editingPatent) {
await adminUpdatePatent(token, editingPatent.id, payload);
setSuccessMsg(`Patent "${payload.title}" updated.`);
} else {
await adminCreatePatent(token, payload);
setSuccessMsg(`Patent "${payload.title}" created.`);
}
setIsPatentModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save patent.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeletePatent = async () => {
if (!deletingPatentId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeletePatent(token, deletingPatentId);
setSuccessMsg("Patent deleted successfully.");
setDeletingPatentId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete patent.");
} finally {
setIsSubmitting(false);
}
};
// 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>
<AdminHeader
title="Publications & Patents Management"
description="Manage journal articles, conference proceedings, and registered laboratory patents."
action={
<button
onClick={tab === "publications" ? handleOpenPubCreate : handleOpenPatentCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
{tab === "publications" ? "+ Add Publication" : "+ Add Patent"}
</button>
}
/>
{/* Tabs */}
<div className="flex border-b border-line mb-6 gap-6">
<button
onClick={() => setTab("publications")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "publications" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Publications ({publications.length})
</button>
<button
onClick={() => setTab("patents")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "patents" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Patents ({patents.length})
</button>
</div>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading records...</div>
) : tab === "publications" ? (
<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">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">
<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 & Venue</th>
<th className="p-4 w-36">Category</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{filteredPubs.length === 0 ? (
<tr>
<td colSpan={4} className="p-6 text-center text-ink/60">
No publications found.
</td>
</tr>
) : (
filteredPubs.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">
<div className="font-semibold text-ink flex items-center gap-2">
{p.title}
{p.is_highlight && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold">
Highlight
</span>
)}
{p.kci && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold">
KCI
</span>
)}
</div>
<div className="text-xs text-ink/70 mt-0.5">
{p.venue} {p.volume && `· ${p.volume}`}
</div>
{p.authors && <div className="text-xs text-ink/50 mt-0.5">{p.authors}</div>}
</td>
<td className="p-4 text-xs text-ink/70">
{p.category === "intl-journal-conf" ? "International" : "Domestic"}
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenPubEdit(p)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingPubId(p.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : (
/* Patents Table */
<div>
{/* Filter sub-bar */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-ink/70">Year:</span>
<select
value={filterPatentYear}
onChange={(e) => setFilterPatentYear(e.target.value)}
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
<option value="all">All Years ({patents.length})</option>
{patentYears.map((yr) => {
const count = patents.filter((p) => {
const d = p.published_at || p.application_at || p.registration_at || "";
return d.includes(yr);
}).length;
return (
<option key={yr} value={yr}>
{yr} ({count})
</option>
);
})}
</select>
</div>
{filterPatentYear !== "all" && (
<button
onClick={() => setFilterPatentYear("all")}
className="text-xs text-cobalt-dark hover:underline font-mono"
>
Reset filter
</button>
)}
<span className="text-xs text-ink/50 ml-auto">
Showing {filteredPatents.length} of {patents.length} records
</span>
</div>
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<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">
{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">
<div className="font-semibold text-ink">{p.title}</div>
<div className="text-xs text-ink/60 mt-0.5">{p.inventors}</div>
</td>
<td className="p-4 text-xs text-ink/80">
<div>App: {p.application_no} ({p.application_at})</div>
{p.registration_no && (
<div className="text-emerald-600 dark:text-emerald-400 font-medium">
Reg: {p.registration_no} ({p.registration_at || "—"})
</div>
)}
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenPatentEdit(p)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingPatentId(p.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* Pub Modal */}
<Modal
isOpen={isPubModalOpen}
title={editingPub ? "Edit Publication" : "Create Publication"}
onClose={() => setIsPubModalOpen(false)}
>
<form onSubmit={handlePubSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Category *</label>
<select
value={pubForm.category}
onChange={(e) =>
setPubForm({
...pubForm,
category: e.target.value as "intl-journal-conf" | "domestic-journal-conf",
})
}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
<option value="intl-journal-conf">International Journal / Conference</option>
<option value="domestic-journal-conf">Domestic Journal / Conference</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Published Date (ISO 8601) *</label>
<input
type="text"
value={pubForm.published_at}
onChange={(e) => setPubForm({ ...pubForm, published_at: e.target.value })}
placeholder="e.g. 2023-11"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Title *</label>
<input
type="text"
value={pubForm.title}
onChange={(e) => setPubForm({ ...pubForm, title: e.target.value })}
placeholder="Full paper title"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Authors</label>
<input
type="text"
value={pubForm.authors}
onChange={(e) => setPubForm({ ...pubForm, authors: e.target.value })}
placeholder="e.g. Gildong Hong, Prof. Advisor"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Venue (Journal / Conference) *</label>
<input
type="text"
value={pubForm.venue}
onChange={(e) => setPubForm({ ...pubForm, venue: e.target.value })}
placeholder="e.g. IEEE Transactions on Communications"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Volume / Pages</label>
<input
type="text"
value={pubForm.volume}
onChange={(e) => setPubForm({ ...pubForm, volume: e.target.value })}
placeholder="e.g. Vol. 70, No. 3, pp. 120-135"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">DOI (optional)</label>
<input
type="text"
value={pubForm.doi}
onChange={(e) => setPubForm({ ...pubForm, doi: e.target.value })}
placeholder="e.g. 10.1109/TCOMM.2023.123456"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex items-center gap-4 pt-5">
<label className="flex items-center gap-2 text-xs font-semibold text-ink cursor-pointer">
<input
type="checkbox"
checked={pubForm.is_highlight}
onChange={(e) => setPubForm({ ...pubForm, is_highlight: e.target.checked })}
className="rounded text-cobalt focus:ring-cobalt"
/>
Home Highlight
</label>
<label className="flex items-center gap-2 text-xs font-semibold text-ink cursor-pointer">
<input
type="checkbox"
checked={pubForm.kci}
onChange={(e) => setPubForm({ ...pubForm, kci: e.target.checked })}
className="rounded text-cobalt focus:ring-cobalt"
/>
KCI Indexed
</label>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsPubModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingPub ? "Save Changes" : "Create Publication"}
</button>
</div>
</form>
</Modal>
{/* Patent Modal */}
<Modal
isOpen={isPatentModalOpen}
title={editingPatent ? "Edit Patent" : "Create Patent"}
onClose={() => setIsPatentModalOpen(false)}
>
<form onSubmit={handlePatentSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Patent Title *</label>
<input
type="text"
value={patentForm.title}
onChange={(e) => setPatentForm({ ...patentForm, title: e.target.value })}
placeholder="e.g. Method and system for optical camera communications"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Inventors *</label>
<input
type="text"
value={patentForm.inventors}
onChange={(e) => setPatentForm({ ...patentForm, inventors: e.target.value })}
placeholder="e.g. Gildong Hong, Advisor"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Country *</label>
<input
type="text"
value={patentForm.country}
onChange={(e) => setPatentForm({ ...patentForm, country: e.target.value })}
placeholder="e.g. Korea or USA"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Application No. *</label>
<input
type="text"
value={patentForm.application_no}
onChange={(e) => setPatentForm({ ...patentForm, application_no: e.target.value })}
placeholder="e.g. 10-2023-0123456"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Application Date *</label>
<input
type="text"
value={patentForm.application_at}
onChange={(e) => setPatentForm({ ...patentForm, application_at: e.target.value })}
placeholder="e.g. 2023-05-12"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Registration No. (optional)</label>
<input
type="text"
value={patentForm.registration_no}
onChange={(e) => setPatentForm({ ...patentForm, registration_no: e.target.value })}
placeholder="e.g. 10-2543210"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Registration Date</label>
<input
type="text"
value={patentForm.registration_at}
onChange={(e) => setPatentForm({ ...patentForm, registration_at: e.target.value })}
placeholder="e.g. 2024-01-10"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Primary Display Year/Date *</label>
<input
type="text"
value={patentForm.published_at}
onChange={(e) => setPatentForm({ ...patentForm, published_at: e.target.value })}
placeholder="e.g. 2023"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsPatentModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingPatent ? "Save Changes" : "Create Patent"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingPubId !== null}
onClose={() => setDeletingPubId(null)}
onConfirm={handleConfirmDeletePub}
isSubmitting={isSubmitting}
title="Delete Publication"
description="Are you sure you want to delete this publication?"
/>
<ConfirmDeleteModal
isOpen={deletingPatentId !== null}
onClose={() => setDeletingPatentId(null)}
onConfirm={handleConfirmDeletePatent}
isSubmitting={isSubmitting}
title="Delete Patent"
description="Are you sure you want to delete this patent record?"
/>
</div>
);
}
@@ -0,0 +1,256 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetResearchAreas,
adminCreateResearchArea,
adminUpdateResearchArea,
adminDeleteResearchArea,
AdminResearchArea,
} from "../../../lib/adminApi";
export default function AdminResearchAreasPage() {
const [areas, setAreas] = useState<AdminResearchArea[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingArea, setEditingArea] = useState<AdminResearchArea | null>(null);
const [formData, setFormData] = useState({
name_en: "",
name_kr: "",
display_order: 0,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const loadAreas = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetResearchAreas();
setAreas(data);
} catch (err: any) {
setError(err?.message || "Failed to load research areas.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadAreas();
}, []);
const handleOpenCreate = () => {
setEditingArea(null);
setFormData({
name_en: "",
name_kr: "",
display_order: areas.length + 1,
});
setIsModalOpen(true);
};
const handleOpenEdit = (a: AdminResearchArea) => {
setEditingArea(a);
setFormData({
name_en: a.name_en,
name_kr: a.name_kr || "",
display_order: a.display_order,
});
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
const payload = {
name_en: formData.name_en.trim(),
name_kr: formData.name_kr.trim() || undefined,
display_order: Number(formData.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingArea) {
await adminUpdateResearchArea(token, editingArea.id, payload);
setSuccessMsg(`Research Area "${payload.name_en}" updated successfully.`);
} else {
await adminCreateResearchArea(token, payload);
setSuccessMsg(`Research Area "${payload.name_en}" created successfully.`);
}
setIsModalOpen(false);
await loadAreas();
} catch (err: any) {
setError(err?.message || "Failed to save research area.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDelete = async () => {
if (!deletingId) return;
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
setIsSubmitting(true);
try {
await adminDeleteResearchArea(token, deletingId);
setSuccessMsg("Research Area deleted successfully.");
setDeletingId(null);
await loadAreas();
} catch (err: any) {
setError(err?.message || "Failed to delete research area.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Research Areas"
description="Manage the key laboratory research area keywords listed in the overview section."
action={
<button
onClick={handleOpenCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add New Area
</button>
}
/>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading research areas...</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-16">Order</th>
<th className="p-4">English Name</th>
<th className="p-4">Korean Name</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{areas.length === 0 ? (
<tr>
<td colSpan={4} className="p-6 text-center text-ink/60">
No research areas found.
</td>
</tr>
) : (
areas.map((a) => (
<tr key={a.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/60">{a.display_order}</td>
<td className="p-4 font-semibold text-ink">{a.name_en}</td>
<td className="p-4 text-ink/80">{a.name_kr || "—"}</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenEdit(a)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingId(a.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
<Modal
isOpen={isModalOpen}
title={editingArea ? "Edit Research Area" : "Create Research Area"}
onClose={() => setIsModalOpen(false)}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
<input
type="text"
value={formData.name_en}
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
placeholder="e.g. Optical Wireless Communications"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Korean Name (optional)</label>
<input
type="text"
value={formData.name_kr}
onChange={(e) => setFormData({ ...formData, name_kr: e.target.value })}
placeholder="e.g. 광무선 통신"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
<input
type="number"
value={formData.display_order}
onChange={(e) => setFormData({ ...formData, display_order: Number(e.target.value) })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingArea ? "Save Changes" : "Create Area"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingId !== null}
onClose={() => setDeletingId(null)}
onConfirm={handleConfirmDelete}
isSubmitting={isSubmitting}
title="Delete Research Area"
description="Are you sure you want to delete this research area keyword?"
/>
</div>
);
}
@@ -0,0 +1,342 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetResearchProjects,
adminCreateResearchProject,
adminUpdateResearchProject,
adminDeleteResearchProject,
AdminResearchProject,
} from "../../../lib/adminApi";
export default function AdminResearchProjectsPage() {
const [projects, setProjects] = useState<AdminResearchProject[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Form State
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<AdminResearchProject | null>(null);
const [formData, setFormData] = useState({
slug: "",
title: "",
abstract: "",
keywords: "",
organization: "",
standards_org: "",
period: "",
funder: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Delete State
const [deletingId, setDeletingId] = useState<number | null>(null);
const loadProjects = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetResearchProjects();
setProjects(data);
} catch (err: any) {
setError(err?.message || "Failed to load research projects.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadProjects();
}, []);
const handleOpenCreate = () => {
setEditingProject(null);
setFormData({
slug: "",
title: "",
abstract: "",
keywords: "",
organization: "",
standards_org: "",
period: "",
funder: "",
});
setIsModalOpen(true);
};
const handleOpenEdit = (p: AdminResearchProject) => {
setEditingProject(p);
setFormData({
slug: p.slug,
title: p.title,
abstract: p.abstract,
keywords: (p.keywords || []).join(", "),
organization: p.organization || "",
standards_org: p.standards_org || "",
period: p.period || "",
funder: p.funder || "",
});
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
const kwArray = formData.keywords
.split(",")
.map((k) => k.trim())
.filter(Boolean);
const payload = {
slug: formData.slug.trim(),
title: formData.title.trim(),
abstract: formData.abstract.trim(),
keywords: kwArray,
organization: formData.organization.trim() || undefined,
standards_org: formData.standards_org.trim() || undefined,
period: formData.period.trim() || undefined,
funder: formData.funder.trim() || undefined,
};
setIsSubmitting(true);
setError(null);
try {
if (editingProject) {
await adminUpdateResearchProject(token, editingProject.id, payload);
setSuccessMsg(`Project "${payload.title}" updated successfully.`);
} else {
await adminCreateResearchProject(token, payload);
setSuccessMsg(`Project "${payload.title}" created successfully.`);
}
setIsModalOpen(false);
await loadProjects();
} catch (err: any) {
setError(err?.message || "Failed to save project.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDelete = async () => {
if (!deletingId) return;
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
setIsSubmitting(true);
try {
await adminDeleteResearchProject(token, deletingId);
setSuccessMsg("Project deleted successfully.");
setDeletingId(null);
await loadProjects();
} catch (err: any) {
setError(err?.message || "Failed to delete project.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Research Projects"
description="Manage key laboratory research projects displayed on the home page."
action={
<button
onClick={handleOpenCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add New Project
</button>
}
/>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading projects...</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">ID</th>
<th className="p-4">Slug</th>
<th className="p-4">Title</th>
<th className="p-4">Organization / Period</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{projects.length === 0 ? (
<tr>
<td colSpan={5} className="p-6 text-center text-ink/60">
No research projects found.
</td>
</tr>
) : (
projects.map((p) => (
<tr key={p.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/60">{p.id}</td>
<td className="p-4 font-mono text-xs text-cobalt font-medium">{p.slug}</td>
<td className="p-4">
<div className="font-semibold text-ink">{p.title}</div>
<div className="text-xs text-ink/60 line-clamp-1 mt-0.5">{p.abstract}</div>
</td>
<td className="p-4 text-xs text-ink/80">
<div>{p.organization || p.standards_org || "—"}</div>
<div className="text-ink/60">{p.period || "—"}</div>
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenEdit(p)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingId(p.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Create / Edit Modal */}
<Modal
isOpen={isModalOpen}
title={editingProject ? "Edit Research Project" : "Create Research Project"}
onClose={() => setIsModalOpen(false)}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Slug *</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="e.g. ccis"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Title *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Project title"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Abstract *</label>
<textarea
value={formData.abstract}
onChange={(e) => setFormData({ ...formData, abstract: e.target.value })}
rows={4}
placeholder="Detailed description of the project"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Keywords (comma separated)</label>
<input
type="text"
value={formData.keywords}
onChange={(e) => setFormData({ ...formData, keywords: e.target.value })}
placeholder="e.g. ISO/IEC 63246, Camera Communications"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Organization / Standards Track</label>
<input
type="text"
value={formData.organization}
onChange={(e) => setFormData({ ...formData, organization: e.target.value })}
placeholder="e.g. ISO/IEC JTC 1/SC 29/WG 11"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Period</label>
<input
type="text"
value={formData.period}
onChange={(e) => setFormData({ ...formData, period: e.target.value })}
placeholder="e.g. 2021.06 - 2024.05"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Funder</label>
<input
type="text"
value={formData.funder}
onChange={(e) => setFormData({ ...formData, funder: e.target.value })}
placeholder="e.g. Ministry of Science and ICT (MSIT)"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingProject ? "Save Changes" : "Create Project"}
</button>
</div>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmDeleteModal
isOpen={deletingId !== null}
onClose={() => setDeletingId(null)}
onConfirm={handleConfirmDelete}
isSubmitting={isSubmitting}
title="Delete Research Project"
description="Are you sure you want to delete this research project? It will be removed from the public website immediately."
/>
</div>
);
}
@@ -0,0 +1,576 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetStandardsBodies,
adminCreateStandardsBody,
adminUpdateStandardsBody,
adminDeleteStandardsBody,
adminCreateStandardDocument,
adminUpdateStandardDocument,
adminDeleteStandardDocument,
AdminStandardsBody,
AdminStandardDocument,
} from "../../../lib/adminApi";
const DOC_STATUSES = ["IS", "TS", "TR", "CDV", "WDTR", "InProgress"];
export default function AdminStandardizationPage() {
const [bodies, setBodies] = useState<AdminStandardsBody[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Body Modal State
const [isBodyModalOpen, setIsBodyModalOpen] = useState(false);
const [editingBody, setEditingBody] = useState<AdminStandardsBody | null>(null);
const [bodyForm, setBodyForm] = useState({
org: "",
full_name: "",
scope: "international" as "international" | "domestic",
period: "",
role: "",
display_order: 0,
});
// Document Modal State
const [isDocModalOpen, setIsDocModalOpen] = useState(false);
const [targetBodyId, setTargetBodyId] = useState<number | null>(null);
const [editingDoc, setEditingDoc] = useState<AdminStandardDocument | null>(null);
const [docForm, setDocForm] = useState({
wg: "",
project_name: "",
title: "",
doc_ref: "",
status: "IS" as "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress",
published_at: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingBodyId, setDeletingBodyId] = useState<number | null>(null);
const [deletingDocId, setDeletingDocId] = useState<number | null>(null);
const loadBodies = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetStandardsBodies();
setBodies(data);
} catch (err: any) {
setError(err?.message || "Failed to load standards bodies.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadBodies();
}, []);
// Body Actions
const handleOpenBodyCreate = () => {
setEditingBody(null);
setBodyForm({
org: "",
full_name: "",
scope: "international",
period: "",
role: "",
display_order: bodies.length + 1,
});
setIsBodyModalOpen(true);
};
const handleOpenBodyEdit = (b: AdminStandardsBody) => {
setEditingBody(b);
setBodyForm({
org: b.org,
full_name: b.full_name,
scope: b.scope,
period: b.period,
role: b.role || "",
display_order: b.display_order,
});
setIsBodyModalOpen(true);
};
const handleBodySubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
org: bodyForm.org.trim(),
full_name: bodyForm.full_name.trim(),
scope: bodyForm.scope,
period: bodyForm.period.trim(),
role: bodyForm.role.trim() || undefined,
display_order: Number(bodyForm.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingBody) {
await adminUpdateStandardsBody(token, editingBody.id, payload);
setSuccessMsg(`Standards Body "${payload.org}" updated.`);
} else {
await adminCreateStandardsBody(token, payload);
setSuccessMsg(`Standards Body "${payload.org}" created.`);
}
setIsBodyModalOpen(false);
await loadBodies();
} catch (err: any) {
setError(err?.message || "Failed to save standards body.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteBody = async () => {
if (!deletingBodyId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteStandardsBody(token, deletingBodyId);
setSuccessMsg("Standards body and associated documents deleted successfully (Cascade).");
setDeletingBodyId(null);
await loadBodies();
} catch (err: any) {
setError(err?.message || "Failed to delete standards body.");
} finally {
setIsSubmitting(false);
}
};
// Doc Actions
const handleOpenDocCreate = (bodyId: number) => {
setTargetBodyId(bodyId);
setEditingDoc(null);
setDocForm({
wg: "",
project_name: "",
title: "",
doc_ref: "",
status: "IS",
published_at: "",
});
setIsDocModalOpen(true);
};
const handleOpenDocEdit = (d: AdminStandardDocument) => {
setTargetBodyId(d.body_id);
setEditingDoc(d);
setDocForm({
wg: d.wg,
project_name: d.project_name,
title: d.title,
doc_ref: d.doc_ref,
status: d.status,
published_at: d.published_at || "",
});
setIsDocModalOpen(true);
};
const handleDocSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token || !targetBodyId) return;
const payload = {
wg: docForm.wg.trim(),
project_name: docForm.project_name.trim(),
title: docForm.title.trim(),
doc_ref: docForm.doc_ref.trim(),
status: docForm.status,
published_at: docForm.published_at.trim() || undefined,
};
setIsSubmitting(true);
setError(null);
try {
if (editingDoc) {
await adminUpdateStandardDocument(token, editingDoc.id, payload);
setSuccessMsg(`Document "${payload.doc_ref}" updated.`);
} else {
await adminCreateStandardDocument(token, targetBodyId, payload);
setSuccessMsg(`Document "${payload.doc_ref}" added.`);
}
setIsDocModalOpen(false);
await loadBodies();
} catch (err: any) {
setError(err?.message || "Failed to save standard document.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteDoc = async () => {
if (!deletingDocId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteStandardDocument(token, deletingDocId);
setSuccessMsg("Standard document deleted successfully.");
setDeletingDocId(null);
await loadBodies();
} catch (err: any) {
setError(err?.message || "Failed to delete document.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Standardization Management"
description="Manage international and domestic standardization organizations, working groups, and published specifications."
action={
<button
onClick={handleOpenBodyCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add Standards Body
</button>
}
/>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading standardization records...</div>
) : bodies.length === 0 ? (
<div className="bg-paper border border-line rounded-lg p-12 text-center text-ink/60">
No standards bodies recorded. Click &quot;+ Add Standards Body&quot; to get started.
</div>
) : (
<div className="space-y-6">
{bodies.map((body) => {
const allDocs = body.projects?.flatMap((p) => p.documents) || [];
return (
<div key={body.id} className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
{/* Body Header Bar */}
<div className="px-6 py-4 bg-ivory border-b border-line flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-3">
<span className="font-bold text-base text-ink">{body.org}</span>
<span className="text-xs font-mono text-ink/60">({body.full_name})</span>
<span
className={`text-[10px] px-2 py-0.5 rounded font-bold uppercase ${
body.scope === "international"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
}`}
>
{body.scope}
</span>
</div>
<div className="text-xs text-ink/60 mt-1">
Period: {body.period} {body.role && `· Role: ${body.role}`} · {allDocs.length} Total Documents
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenDocCreate(body.id)}
className="px-2.5 py-1 text-xs rounded bg-cobalt text-white font-medium hover:bg-cobalt/90"
>
+ Add Document
</button>
<button
onClick={() => handleOpenBodyEdit(body)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingBodyId(body.id)}
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
>
Delete Body
</button>
</div>
</div>
{/* Documents Table */}
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="border-b border-line text-xs font-semibold text-ink/60 bg-paper">
<th className="p-3 w-28">Ref / Status</th>
<th className="p-3">Title & WG</th>
<th className="p-3">Project Group</th>
<th className="p-3 w-24">Date</th>
<th className="p-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{allDocs.length === 0 ? (
<tr>
<td colSpan={5} className="p-4 text-center text-xs text-ink/50">
No documents added for this standards body.
</td>
</tr>
) : (
allDocs.map((d) => (
<tr key={d.id} className="hover:bg-ivory/40 text-xs">
<td className="p-3">
<div className="font-mono font-semibold text-cobalt">{d.doc_ref}</div>
<span className="inline-block mt-0.5 px-1.5 py-0.2 text-[10px] rounded bg-ink/5 text-ink/70 font-bold">
{d.status}
</span>
</td>
<td className="p-3">
<div className="font-medium text-ink">{d.title}</div>
<div className="text-[11px] text-ink/50 mt-0.5">{d.wg}</div>
</td>
<td className="p-3 text-ink/70">{d.project_name}</td>
<td className="p-3 font-mono text-ink/60">{d.published_at || "—"}</td>
<td className="p-3 text-right space-x-2">
<button
onClick={() => handleOpenDocEdit(d)}
className="px-2 py-0.5 rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingDocId(d.id)}
className="px-2 py-0.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 font-medium"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
})}
</div>
)}
{/* Body Modal */}
<Modal
isOpen={isBodyModalOpen}
title={editingBody ? "Edit Standards Body" : "Create Standards Body"}
onClose={() => setIsBodyModalOpen(false)}
>
<form onSubmit={handleBodySubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Organization Acronym *</label>
<input
type="text"
value={bodyForm.org}
onChange={(e) => setBodyForm({ ...bodyForm, org: e.target.value })}
placeholder="e.g. ISO/IEC"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Scope *</label>
<select
value={bodyForm.scope}
onChange={(e) => setBodyForm({ ...bodyForm, scope: e.target.value as "international" | "domestic" })}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
<option value="international">International</option>
<option value="domestic">Domestic</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Full Organization Name *</label>
<input
type="text"
value={bodyForm.full_name}
onChange={(e) => setBodyForm({ ...bodyForm, full_name: e.target.value })}
placeholder="e.g. International Organization for Standardization"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Period *</label>
<input
type="text"
value={bodyForm.period}
onChange={(e) => setBodyForm({ ...bodyForm, period: e.target.value })}
placeholder="e.g. 2020 - Present"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Role in Committee</label>
<input
type="text"
value={bodyForm.role}
onChange={(e) => setBodyForm({ ...bodyForm, role: e.target.value })}
placeholder="e.g. Project Leader"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsBodyModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingBody ? "Save Changes" : "Create Body"}
</button>
</div>
</form>
</Modal>
{/* Doc Modal */}
<Modal
isOpen={isDocModalOpen}
title={editingDoc ? "Edit Standard Document" : "Add Standard Document"}
onClose={() => setIsDocModalOpen(false)}
>
<form onSubmit={handleDocSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Document Reference *</label>
<input
type="text"
value={docForm.doc_ref}
onChange={(e) => setDocForm({ ...docForm, doc_ref: e.target.value })}
placeholder="e.g. ISO/IEC 63246-1"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Status *</label>
<select
value={docForm.status}
onChange={(e) =>
setDocForm({
...docForm,
status: e.target.value as "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress",
})
}
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
{DOC_STATUSES.map((st) => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Document Title *</label>
<input
type="text"
value={docForm.title}
onChange={(e) => setDocForm({ ...docForm, title: e.target.value })}
placeholder="e.g. Part 1: Architecture and functional model"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Working Group (WG) *</label>
<input
type="text"
value={docForm.wg}
onChange={(e) => setDocForm({ ...docForm, wg: e.target.value })}
placeholder="e.g. ISO/IEC JTC 1/SC 29/WG 11"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Project Name *</label>
<input
type="text"
value={docForm.project_name}
onChange={(e) => setDocForm({ ...docForm, project_name: e.target.value })}
placeholder="e.g. CCIS (Camera Communications)"
required
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Published Date (ISO 8601 optional)</label>
<input
type="text"
value={docForm.published_at}
onChange={(e) => setDocForm({ ...docForm, published_at: e.target.value })}
placeholder="e.g. 2023-08"
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsDocModalOpen(false)}
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Saving..." : editingDoc ? "Save Changes" : "Add Document"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingBodyId !== null}
onClose={() => setDeletingBodyId(null)}
onConfirm={handleConfirmDeleteBody}
isSubmitting={isSubmitting}
title="Delete Standards Body (Cascade Warning)"
description="Are you sure you want to delete this standards body? All standard documents under this body will also be permanently deleted (Database Cascade)."
/>
<ConfirmDeleteModal
isOpen={deletingDocId !== null}
onClose={() => setDeletingDocId(null)}
onConfirm={handleConfirmDeleteDoc}
isSubmitting={isSubmitting}
title="Delete Standard Document"
description="Are you sure you want to delete this standard document?"
/>
</div>
);
}
@@ -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}</>;
}
+11 -10
View File
@@ -1,16 +1,17 @@
import React from "react";
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
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);
+10
View File
@@ -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}</>;
}
+16 -12
View File
@@ -1,14 +1,9 @@
import React from "react";
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
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 +15,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) =>
+20 -8
View File
@@ -1,10 +1,11 @@
import React from "react";
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
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,15 +15,26 @@ 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">
@@ -0,0 +1,192 @@
"use client";
import React, { useEffect, useState } from "react";
import { notFound } from "next/navigation";
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 unique sorted years
const availableYears = Array.from(
new Set(
allRecords
.map((r) => (r.publishedAt || "").match(/\b(19\d\d|20\d\d)\b/)?.[1])
.filter((y): y is string => Boolean(y))
)
).sort((a, b) => b.localeCompare(a));
const records =
selectedYear === "all"
? allRecords
: allRecords.filter((r) => (r.publishedAt || "").includes(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">
<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 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>
{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} />;
}
@@ -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}</>;
}
+15 -10
View File
@@ -1,17 +1,22 @@
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
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,
@@ -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}</>;
}
+11 -10
View File
@@ -1,16 +1,17 @@
import React from "react";
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
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 */}
+8
View File
@@ -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 */}
+5 -1
View File
@@ -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%">
+494
View File
@@ -0,0 +1,494 @@
/**
* Admin API client for /console dashboard.
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
*/
export function normalizeApiUrl(rawUrl?: string): string {
let url = (rawUrl || "").trim().replace(/\/+$/, "");
if (!url || url === "/" || url === "/api" || url === "/api/v1") {
return "/api/v1";
}
if (url.endsWith("/api/v1")) {
return url;
}
if (url.endsWith("/api")) {
return `${url}/v1`;
}
return `${url}/api/v1`;
}
export function getAdminApiBaseUrl(): string {
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;
slug: string;
title: string;
abstract: string;
keywords: string[];
organization?: string;
standards_org?: string;
period?: string;
funder?: string;
created_at?: string;
updated_at?: string;
}
export interface AdminResearchArea {
id: number;
name_en: string;
name_kr?: string;
display_order: number;
}
export interface AdminMember {
id: number;
name_ko: string;
name_en: string;
degree: string;
affiliation?: string;
email?: string;
is_advisor: boolean;
display_order: number;
created_at?: string;
}
export interface AdminAlumnus {
id: number;
name_ko: string;
name_en: string;
degree: string;
graduated_at: string;
major?: string;
current_position?: string;
created_at?: string;
}
export interface AdminPublication {
id: number;
category: "intl-journal-conf" | "domestic-journal-conf";
title: string;
authors?: string;
venue: string;
volume?: string;
published_at: string;
kci: boolean;
doi?: string;
is_highlight: boolean;
created_at?: string;
}
export interface AdminPatent {
id: number;
title: string;
inventors: string;
application_no: string;
application_at: string;
registration_no?: string;
registration_at?: string;
country: string;
published_at: string;
created_at?: string;
}
export interface AdminCourse {
id: number;
semester_id: number;
code: string;
name_en: string;
name_kr?: string;
note?: string;
display_order: number;
}
export interface AdminSemester {
id: number;
year: number;
term: "Spring" | "Fall";
courses: AdminCourse[];
}
export interface AdminStandardDocument {
id: number;
body_id: number;
wg: string;
project_name: string;
title: string;
doc_ref: string;
status: "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress";
published_at?: string;
}
export interface AdminStandardProjectGroup {
wg: string;
name: string;
documents: AdminStandardDocument[];
}
export interface AdminStandardsBody {
id: number;
org: string;
full_name: string;
scope: "international" | "domestic";
period: string;
role?: string;
display_order: number;
projects: AdminStandardProjectGroup[];
}
async function request<T>(
endpoint: string,
options: RequestInit = {},
token?: string
): Promise<T> {
const headers = new Headers(options.headers || {});
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (options.body && typeof options.body === "string") {
headers.set("Content-Type", "application/json");
}
const baseUrl = getAdminApiBaseUrl();
const url = `${baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
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);
}
}
// 0. Auth Verification
export async function adminVerifyToken(token: string): Promise<boolean> {
try {
await request<{ status: string }>("/admin/verify", { method: "GET" }, token);
return true;
} catch {
return false;
}
}
// 2. Research Projects
export async function adminGetResearchProjects(): Promise<AdminResearchProject[]> {
return request<AdminResearchProject[]>("/research-projects");
}
export async function adminCreateResearchProject(
token: string,
data: Omit<AdminResearchProject, "id" | "created_at" | "updated_at">
): Promise<AdminResearchProject> {
return request<AdminResearchProject>("/research-projects", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateResearchProject(
token: string,
id: number,
data: Partial<AdminResearchProject>
): Promise<AdminResearchProject> {
return request<AdminResearchProject>(`/research-projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteResearchProject(token: string, id: number): Promise<void> {
return request<void>(`/research-projects/${id}`, { method: "DELETE" }, token);
}
// 3. Research Areas
export async function adminGetResearchAreas(): Promise<AdminResearchArea[]> {
return request<AdminResearchArea[]>("/research-areas");
}
export async function adminCreateResearchArea(
token: string,
data: Omit<AdminResearchArea, "id">
): Promise<AdminResearchArea> {
return request<AdminResearchArea>("/research-areas", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateResearchArea(
token: string,
id: number,
data: Partial<AdminResearchArea>
): Promise<AdminResearchArea> {
return request<AdminResearchArea>(`/research-areas/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteResearchArea(token: string, id: number): Promise<void> {
return request<void>(`/research-areas/${id}`, { method: "DELETE" }, token);
}
// 4. Members & Alumni
export async function adminGetMembers(): Promise<AdminMember[]> {
return request<AdminMember[]>("/members");
}
export async function adminCreateMember(
token: string,
data: Omit<AdminMember, "id" | "created_at">
): Promise<AdminMember> {
return request<AdminMember>("/members", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateMember(
token: string,
id: number,
data: Partial<AdminMember>
): Promise<AdminMember> {
return request<AdminMember>(`/members/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteMember(token: string, id: number): Promise<void> {
return request<void>(`/members/${id}`, { method: "DELETE" }, token);
}
export async function adminGetAlumni(): Promise<AdminAlumnus[]> {
return request<AdminAlumnus[]>("/alumni");
}
export async function adminCreateAlumnus(
token: string,
data: Omit<AdminAlumnus, "id" | "created_at">
): Promise<AdminAlumnus> {
return request<AdminAlumnus>("/alumni", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateAlumnus(
token: string,
id: number,
data: Partial<AdminAlumnus>
): Promise<AdminAlumnus> {
return request<AdminAlumnus>(`/alumni/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteAlumnus(token: string, id: number): Promise<void> {
return request<void>(`/alumni/${id}`, { method: "DELETE" }, token);
}
// 5. Publications & Patents
export async function adminGetPublications(category?: string): Promise<AdminPublication[]> {
const query = category ? `?category=${category}` : "";
return request<AdminPublication[]>(`/publications${query}`);
}
export async function adminCreatePublication(
token: string,
data: Omit<AdminPublication, "id" | "created_at">
): Promise<AdminPublication> {
return request<AdminPublication>("/publications", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdatePublication(
token: string,
id: number,
data: Partial<AdminPublication>
): Promise<AdminPublication> {
return request<AdminPublication>(`/publications/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeletePublication(token: string, id: number): Promise<void> {
return request<void>(`/publications/${id}`, { method: "DELETE" }, token);
}
export async function adminGetPatents(): Promise<AdminPatent[]> {
return request<AdminPatent[]>("/patents");
}
export async function adminCreatePatent(
token: string,
data: Omit<AdminPatent, "id" | "created_at">
): Promise<AdminPatent> {
return request<AdminPatent>("/patents", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdatePatent(
token: string,
id: number,
data: Partial<AdminPatent>
): Promise<AdminPatent> {
return request<AdminPatent>(`/patents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeletePatent(token: string, id: number): Promise<void> {
return request<void>(`/patents/${id}`, { method: "DELETE" }, token);
}
// 6. Lectures (Semesters & Courses)
export async function adminGetSemesters(): Promise<AdminSemester[]> {
return request<AdminSemester[]>("/semesters");
}
export async function adminCreateSemester(
token: string,
data: Omit<AdminSemester, "id" | "courses">
): Promise<AdminSemester> {
return request<AdminSemester>("/semesters", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateSemester(
token: string,
id: number,
data: Partial<AdminSemester>
): Promise<AdminSemester> {
return request<AdminSemester>(`/semesters/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteSemester(token: string, id: number): Promise<void> {
return request<void>(`/semesters/${id}`, { method: "DELETE" }, token);
}
export async function adminCreateCourse(
token: string,
semesterId: number,
data: Omit<AdminCourse, "id" | "semester_id">
): Promise<AdminCourse> {
return request<AdminCourse>(`/semesters/${semesterId}/courses`, {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateCourse(
token: string,
id: number,
data: Partial<AdminCourse>
): Promise<AdminCourse> {
return request<AdminCourse>(`/courses/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteCourse(token: string, id: number): Promise<void> {
return request<void>(`/courses/${id}`, { method: "DELETE" }, token);
}
// 7. Standardization (Bodies & Documents)
export async function adminGetStandardsBodies(): Promise<AdminStandardsBody[]> {
return request<AdminStandardsBody[]>("/standards-bodies");
}
export async function adminCreateStandardsBody(
token: string,
data: Omit<AdminStandardsBody, "id" | "projects">
): Promise<AdminStandardsBody> {
return request<AdminStandardsBody>("/standards-bodies", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateStandardsBody(
token: string,
id: number,
data: Partial<AdminStandardsBody>
): Promise<AdminStandardsBody> {
return request<AdminStandardsBody>(`/standards-bodies/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteStandardsBody(token: string, id: number): Promise<void> {
return request<void>(`/standards-bodies/${id}`, { method: "DELETE" }, token);
}
export async function adminCreateStandardDocument(
token: string,
bodyId: number,
data: Omit<AdminStandardDocument, "id" | "body_id">
): Promise<AdminStandardDocument> {
return request<AdminStandardDocument>(`/standards-bodies/${bodyId}/documents`, {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateStandardDocument(
token: string,
id: number,
data: Partial<AdminStandardDocument>
): Promise<AdminStandardDocument> {
return request<AdminStandardDocument>(`/standard-documents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteStandardDocument(token: string, id: number): Promise<void> {
return request<void>(`/standard-documents/${id}`, { method: "DELETE" }, token);
}
+32 -2
View File
@@ -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",
});
+2 -9
View File
@@ -1,15 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
trailingSlash: true,
reactStrictMode: true,
async redirects() {
return [
{
source: "/intro",
destination: "/",
permanent: true, // 308 permanent redirect (H-11 / IA-02)
},
];
},
};
module.exports = nextConfig;
@@ -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", [])
+139 -171
View File
@@ -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())
@@ -96,28 +97,55 @@ def test_build():
print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}")
# Check exact match for each dynamic route
for d_route in EXPECTED_DYNAMIC_ROUTES:
mode = route_table_map.get(d_route)
if mode != "ƒ":
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
return False
# Filter public routes vs admin console routes
public_route_table = {k: v for k, v in route_table_map.items() if not k.startswith("/console")}
console_routes = {k: v for k, v in route_table_map.items() if k.startswith("/console")}
print(f"Public routes ({len(public_route_table)}): {public_route_table}")
if console_routes:
print(f"Admin Console routes ({len(console_routes)}): {console_routes}")
# Check exact match for each static route
# Check exact match for each static public route
for s_route in EXPECTED_STATIC_ROUTES:
mode = route_table_map.get(s_route)
mode = public_route_table.get(s_route)
if mode not in ("", ""):
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '' or ''")
return False
# Check that no other unexpected routes exist in table
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
found_routes = set(route_table_map.keys())
if all_expected != found_routes:
print(f"❌ Gate 2 Violation: Route set mismatch. Missing: {all_expected - found_routes}, Extra: {found_routes - all_expected}")
# Check that public route set matches exactly
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():
@@ -129,30 +157,39 @@ 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 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)
def start_unified_go_server(port=8080):
backend_dir = os.path.join(ROOT_DIR, "backend")
out_dir = os.path.join(REFER_DIR, "out")
web_dir = os.path.join(backend_dir, "web")
# 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"] = str(port)
b_env["GIN_MODE"] = "release"
b_env["ADMIN_TOKEN"] = "dev_admin_secret_token_12345"
proc = subprocess.Popen(
["npm", "run", "start"],
cwd=REFER_DIR,
["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,
text=True,
env=server_env,
env=b_env,
)
ready = False
for _ in range(30):
for _ in range(40):
try:
with urllib.request.urlopen(f"http://localhost:{port}/", 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
@@ -161,12 +198,12 @@ def start_next_server(port=3000):
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}")
raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}")
print(f"✅ Unified Go 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}"
@@ -236,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}"})
@@ -249,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:
@@ -262,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")
@@ -294,140 +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
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")
@@ -435,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
@@ -452,11 +421,10 @@ def main():
print("\n❌ Build or static gates failed before live server execution.")
sys.exit(1)
# Start live Next.js server once and run Gate 4, Gate 7, Gate 8
port = get_free_port()
server_proc = None
try:
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)
@@ -464,7 +432,7 @@ 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)
+16 -91
View File
@@ -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 &amp; 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)
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# generate-env.sh — create a local .mam.env from the committed .mam.env.example template.
#
# Behaviour:
# - .mam.env absent → copy .mam.env.example to .mam.env, print the path.
# - .mam.env present → no-op (leaves your edits intact), exit 0.
# - .mam.env present --force → overwrite .mam.env from .mam.env.example (backs up to .mam.env.bak).
# - --migrate-legacy → explicitly rename existing .env to .mam.env if absent.
#
# Paths are resolved relative to this script (repo root = parent of deploy/),
# so it works regardless of the caller's cwd.
#
# Usage: deploy/generate-env.sh [--force] [--migrate-legacy] [-h|--help]
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$REPO_ROOT/.mam.env.example"
DST="$REPO_ROOT/.mam.env"
LEGACY_ENV="$REPO_ROOT/.env"
FORCE=0
MIGRATE=0
while [ $# -gt 0 ]; do
case "$1" in
--force) FORCE=1; shift ;;
--migrate-legacy) MIGRATE=1; shift ;;
-h|--help)
echo "Usage: $0 [--force] [--migrate-legacy]"
echo " Create .mam.env from .mam.env.example."
echo " --force overwrites an existing .mam.env (backs up to .mam.env.bak)."
echo " --migrate-legacy explicitly renames an existing legacy .env to .mam.env."
exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; echo "Usage: $0 [--force] [--migrate-legacy]" >&2; exit 2 ;;
esac
done
if [ "$MIGRATE" = "1" ]; then
if [ -f "$DST" ]; then
echo "ERROR: cannot migrate: $DST already exists." >&2
exit 1
fi
if [ ! -f "$LEGACY_ENV" ]; then
echo "ERROR: cannot migrate: legacy file $LEGACY_ENV not found." >&2
exit 1
fi
mv -f "$LEGACY_ENV" "$DST"
chmod 0600 "$DST" 2>/dev/null || true
echo "migrated: $LEGACY_ENV -> $DST"
exit 0
fi
[ -f "$SRC" ] || { echo "ERROR: template not found: $SRC" >&2; exit 1; }
if [ -f "$DST" ] && [ "$FORCE" != "1" ]; then
echo "no-op: $DST already exists (use --force to overwrite)"
exit 0
fi
if [ -f "$DST" ] && [ "$FORCE" = "1" ]; then
cp -p "$DST" "$DST.bak"
echo "backed up existing .mam.env -> $DST.bak"
fi
cp "$SRC" "$DST"
chmod 0600 "$DST" 2>/dev/null || true
echo "created: $DST"
echo "Next: edit $DST and fill in any secrets (look for 'replace_me')."