Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3972cac27 | ||
|
|
b0bce3be8e | ||
|
|
ca6e85ce9b | ||
|
|
3dc89970c6 | ||
|
|
cda9c6ea4f | ||
|
|
d1213e5c7f | ||
|
|
78a66b7ad4 | ||
|
|
1410dcf89d | ||
|
|
d3dc7f19d9 | ||
|
|
4c78ea819f | ||
|
|
5285e3fd02 | ||
|
|
575fbdb6de | ||
|
|
643ce8376c | ||
|
|
54fe425b3c | ||
|
|
9bdc9bdd9a | ||
|
|
5f87631530 | ||
|
|
0a589bd3a2 | ||
|
|
e532e68abd | ||
|
|
50cad60c07 | ||
|
|
19f5056bef | ||
|
|
56c8e82dfe | ||
|
|
11f44f65da | ||
|
|
c2703c131d | ||
|
|
74b637bcc9 | ||
|
|
3f452468d6 |
@@ -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
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
|
|
||||||
# Binary zip archives
|
# Binary zip archives
|
||||||
*.zip
|
*.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) >>>
|
# >>> MAM managed block (managed by install.sh — do not edit) >>>
|
||||||
/.venv/
|
/.venv/
|
||||||
/.mam/
|
/.mam/
|
||||||
@@ -18,3 +25,5 @@
|
|||||||
/.mam-skill-backup.*/
|
/.mam-skill-backup.*/
|
||||||
CURRENT_JOB.md
|
CURRENT_JOB.md
|
||||||
# <<< MAM managed block <<<
|
# <<< MAM managed block <<<
|
||||||
|
.env
|
||||||
|
docker-compose.yaml
|
||||||
+2
-2
@@ -129,8 +129,8 @@
|
|||||||
# SKS_EMPTY_GIVEUP=3
|
# SKS_EMPTY_GIVEUP=3
|
||||||
|
|
||||||
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
|
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
|
||||||
#default: 60
|
#default: 40
|
||||||
# MAM_MIN_PANE_COLS=60
|
# MAM_MIN_PANE_COLS=40
|
||||||
|
|
||||||
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
|
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
|
||||||
#default: 20
|
#default: 20
|
||||||
|
|||||||
@@ -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)
|
- **연구실명**: AI 에이전트 네트워크 연구실 (ANL / AI Agent Networking Laboratory)
|
||||||
- **소속**: 경북대학교 IT대학 컴퓨터학부
|
- **소속**: 경북대학교 IT대학 컴퓨터학부
|
||||||
- **지도교수**: 고석주 교수 (Prof. Seok-Joo Koh / `sjkoh@knu.ac.kr`)
|
- **지도교수**: 고석주 교수 (Prof. Seok-Joo Koh / `sjkoh@knu.ac.kr`)
|
||||||
- **연구 패러다임 전환 (Research Paradigm Shift)**:
|
- **연구 패러다임**:
|
||||||
- *Legacy (과거)*: 단일 디바이스 연동 및 전통적 사물인터넷(oneM2M, OCF) 표준 연구
|
- **Present & Future**: **MCP & SKILL**, **멀티에이전트 오케스트레이션 (HERDR, crewAI, LangGraph)**, **이종 에이전트 상호운용성 표준 (ACP, A2A)**, 및 **AIoT gRPC/QUIC 고성능 통신 백본**
|
||||||
- **Present (현재 핵심 연구)**: **MCP & SKILL**, **멀티에이전트 오케스트레이션 인프라 (HERDR, LangGraph)**, **이종 에이전트 상호운용성 표준 (ACP, A2A)**, 및 **AIoT gRPC/QUIC 고성능 통신 백본**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💡 2. 핵심 연구 분야 (Core Research Areas)
|
## 💡 2. 핵심 연구 분야 (Core Research Areas)
|
||||||
|
|
||||||
`multi-agent-orchestration/` 연구 자산에 기반한 ANL 연구실의 주요 연구 분야입니다:
|
|
||||||
|
|
||||||
### 🔌 1) AI Agent 기능 확장 프로토콜 (MCP & SKILL)
|
### 🔌 1) AI Agent 기능 확장 프로토콜 (MCP & SKILL)
|
||||||
- **MCP (Model Context Protocol)**: Anthropic 제정 오픈 규격으로 로컬/원격 도구, 데이터(Resource), 프롬프트 통일 호출 인터페이스
|
- **MCP (Model Context Protocol)**: Anthropic 제정 오픈 규격으로 도구, 데이터 리소스, 프롬프트 표준 인터페이스 연동
|
||||||
- **SKILL (자율 확장 도구 모음)**: 실행 코드 + 도구 명세 스키마 + Few-shot이 결합된 모듈화 패키징 기술
|
- **SKILL (자율 확장 도구 패키지)**: 실행 코드 + 도구 명세 + Few-shot이 결합된 모듈형 에이전트 기능 확장
|
||||||
- **CLI 코딩 에이전트**: Claude Code, Antigravity CLI 등 터미널 기반 파일 제어 및 자율 소프트웨어 엔지니어링
|
- **CLI 자율 코딩 에이전트**: Claude Code, Antigravity CLI 등 터미널 기반 자율 소프트웨어 엔지니어링
|
||||||
|
|
||||||
### 🤖 2) 멀티 에이전트 협업 및 오케스트레이션 인프라 (Multi-Agent Orchestration)
|
### 🤖 2) 멀티 에이전트 협업 및 오케스트레이션 (Multi-Agent Orchestration)
|
||||||
- **단일 에이전트 한계 극복**: Lost in the middle 현상 방지, 환각(Hallucination) 억제 및 역할 세분화(Role Specialization)
|
- **이종 에이전트 교차 검증**: 서로 다른 LLM 모델(Planner, Creator, Reviewer) 간 비평/합의 루프로 무결성 확보
|
||||||
- **이종 에이전트 교차 검증**: 서로 다른 LLM 모델간 비평/합의 루프를 통한 신뢰성 극대화
|
|
||||||
- **오케스트레이션 인프라 코어**:
|
- **오케스트레이션 인프라 코어**:
|
||||||
- **HERDR**: 가상 백그라운드 프로세스 컨테이너 기반 에이전트 세션 격리 및 TUI 라이프사이클 관제
|
- **HERDR**: 가상 백그라운드 프로세스 격리 및 에이전트 세션 TUI 관제
|
||||||
- **crewAI**: 역할(Role), 목표(Goal), 배경(Backstory) 기반 순차/계층 크루 프로세스
|
- **LangGraph & crewAI**: 상태 보존형 순환 그래프, 역할 기반 분업 및 예외 롤백 처리
|
||||||
- **LangGraph**: 상태 보존형 순환 그래프(Cyclic Graph), 루프 제어, 예외 롤백 및 상태 동기화
|
|
||||||
|
|
||||||
### 🌐 3) 에이전트 상호운용성 개방형 표준 (Agent Interoperability: ACP & A2A)
|
### 🌐 3) 에이전트 상호운용성 개방형 표준 (Agent Interoperability: ACP & A2A)
|
||||||
- **ACP (Agent Communication Protocol)**: IBM Research 제정 서비스 발견 및 태스크 위임 오픈 표준
|
- **ACP (Agent Communication Protocol)**: IBM Research 제정 서비스 발견 및 태스크 위임 오픈 표준
|
||||||
- **A2A (Agent-to-Agent Protocol)**: Google 및 Linux Foundation 주도의 이종 에이전트 간 표준 메시지 교환 및 상태 추적 통신 표준
|
- **A2A (Agent-to-Agent Protocol)**: Google/Linux Foundation 주도 이종 에이전트 간 표준 메시지 교환 통신 표준
|
||||||
- **Agent Card (`/.well-known/agent-card.json`)**: 에이전트의 역량(Capabilities), 엔드포인트, 보안 사양을 명시하는 표준 JSON 프로필
|
- **Agent Card (`/.well-known/agent-card.json`)**: 에이전트 역량(Capabilities) 및 보안 사양 표준 프로필
|
||||||
|
|
||||||
### ⚡ 4) 지능형 사물인터넷 (AIoT) & 고성능 통신 백본 (AIoT & gRPC/QUIC)
|
### ⚡ 4) 지능형 사물인터넷 (AIoT) & 고성능 백본 (AIoT & gRPC/QUIC)
|
||||||
- **AIoT 분산 자율 제어**: 스마트 팩토리/스마트 팜 등 Physical 센서 계층과 엣지 연산 계층 간 멀티 에이전트 분산 배치
|
- **AIoT 분산 자율 제어**: 센서 계층과 엣지 연산 계층 간 멀티 에이전트 분산 배치
|
||||||
- **gRPC & QUIC 양방향 스트리밍**: HTTP/2 & HTTP/3 기반 Protobuf 직렬화 및 대용량 멀티모달 바이너리 고속 전송
|
- **gRPC & QUIC 양방향 스트리밍**: HTTP/2 & HTTP/3 기반 고속 바이너리 멀티모달 전송
|
||||||
- **하이브리드 백본 메쉬**: 센서 계층의 경량 프로토콜(MQTT/CoAP)과 엣지 백본의 고성능 프로토콜(gRPC/QUIC) 완충 혼용
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 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/
|
landing_page/
|
||||||
├── README.md # [본 문서] 5대 연구 기둥 반영 통합 온보딩 가이드
|
├── README.md # [본 문서] 종합 프로젝트 가이드 및 배포/운영 문서
|
||||||
├── ISL-2026/ # 📄 [실제 데이터 원본] 기존 연구실 공식 홈페이지 (HTML/CSS 및 교수/연구원/실적 데이터)
|
├── docker-compose.yml # 통합 단일 컨테이너 (anl-app) Compose 오케스트레이션
|
||||||
│ ├── index.html # 메인 랜딩페이지 & 레거시 연구 분야 소스
|
├── .env.example # 환경변수 설정 템플릿
|
||||||
│ ├── member.html # 교수진, 석/박사 연구원, 학부연구생, 졸업생 목록
|
|
||||||
│ ├── paper-international.html # 국외 저널 및 학회 논문 실적
|
|
||||||
│ ├── paper-domestic.html # 국내 저널 및 학회 논문 실적
|
|
||||||
│ ├── patent.html # 특허 등록/출원 실적
|
|
||||||
│ └── standard.html # 국제/국내 표준화 기여 내역 (oneM2M, ITU-T 등)
|
|
||||||
│
|
│
|
||||||
├── multi-agent-orchestration/ # 📚 [신규 연구 분야 상세 교육 & 세미나 자료]
|
├── backend/ # ⚡ [통합 Go 백엔드 & 정적 웹 서빙 서버]
|
||||||
│ ├── lecture_summary.md # AI Multi-Agents 오케스트레이션 핵심 기술 요약
|
│ ├── cmd/ # 진입점 (api: 통합 서버, seed: 시더, export-content)
|
||||||
│ ├── SLIDE.md # 연구 세미나 통합 발표 자료
|
│ ├── internal/ # 비즈니스 로직 (handlers, repository, router, config, httpx)
|
||||||
│ ├── implementation_plan.md # 오케스트레이션 검토 및 반영 루프 계획
|
│ ├── db/backup/ # 💾 데이터베이스 백업 (anl.db 바이너리, anl_dump.sql 전체 덤프)
|
||||||
│ └── chapters/ # 0~6장 챕터별 상세 연구 문서 (Prompt, MCP, HERDR, A2A, gRPC 등)
|
│ ├── 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
|
├── refer_landing_page/ # 🚀 [Next.js 14 프론트엔드 & 관리자 콘솔 소스]
|
||||||
│ ├── app/ # Next.js App Router 페이지 (Home /, members, publications, lectures, standardization)
|
│ ├── app/ # App Router 페이지 (공개 페이지 및 /console 관리자 대시보드)
|
||||||
│ ├── components/ # 모듈화된 UI 컴포넌트 (Header, Footer, PageHeader, SectionLabel, Reveal, Counter, Marquee)
|
│ ├── components/ # UI 컴포넌트 (Header, Footer, Reveal, Counter, Marquee 등)
|
||||||
│ ├── docs/DESIGN.md # 고대비 매거진 에디토리얼 디자인 시스템 가이드 문서
|
│ ├── lib/ # API 통신 클라이언트 (api.ts: 조회, adminApi.ts: CRUD)
|
||||||
│ └── tailwind.config.ts # 디자인 토큰 및 시각 스타일 규칙 정의
|
│ ├── docs/ # 디자인 시스템 (DESIGN.md: Issue 01 에디토리얼 스타일)
|
||||||
|
│ ├── scripts/ # E2E 테스트 스위트 및 데이터 검증 러너
|
||||||
|
│ ├── next.config.js # output: "export" 정적 익스포트 설정
|
||||||
|
│ └── package.json # Next.js, React, Tailwind CSS 의존성
|
||||||
│
|
│
|
||||||
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 규칙 및 스킬 툴킷 (MAM)
|
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 스킬 & 룰셋 (MAM)
|
||||||
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
|
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
|
||||||
└── docs/ & research/ # 작업 노트, 결정 기록(ADR) 및 연구 논문 아카이브
|
└── ISL-2026/ # 📄 레거시 실측 원본 HTML/CSS 아카이브
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 4. 온보딩 개발 미션 (Development Missions for Agents)
|
## 🚀 5. Docker 컨테이너 배포 및 운영 가이드 (Production Deployment)
|
||||||
|
|
||||||
### 📥 Mission 1: 실제 레거시 데이터 마이그레이션 & 신규 5대 연구 기둥 반영
|
단일 컨테이너(Single Unified Container)를 통해 프론트엔드 정적 웹 에셋과 Go 백엔드 API를 동시에 빌드하고 배포합니다.
|
||||||
- `ISL-2026/` 레거시 데이터(교수진, 연구원, 논문, 특허, 강의)를 추출하여 `refer_landing_page/app/<route>/page.tsx` 내 데이터 구조로 교체합니다.
|
|
||||||
- 연구실 메인 랜딩페이지(`app/page.tsx`) 상단 비주얼, 키워드 마퀴(`Marquee`), 카운터 수치(`Counter`)에 **A2A, MCP, HERDR, LangGraph, gRPC/QUIC** 등 5대 신규 연구 키워드 및 성과를 전면 강조합니다.
|
|
||||||
|
|
||||||
### 🎨 Mission 2: 고대비 에디토리얼 디자인 시스템 준수
|
### 1) 저장소 클론 및 환경 설정
|
||||||
- `refer_landing_page/docs/DESIGN.md`에 명시된 에디토리얼 디자인 시스템("Issue 01") 규격을 엄격히 준수합니다.
|
```bash
|
||||||
- `ISL-2026/assets/`의 로고 및 인물/연구 사진을 `refer_landing_page/public/`으로 이관합니다.
|
# 1. 저장소 클론
|
||||||
|
git clone <리포지토리_URL> landing_page
|
||||||
|
cd landing_page
|
||||||
|
|
||||||
### 🔍 Mission 3: SEO, 반응형 UI & 빌드 검증
|
# 2. 환경변수 파일 생성
|
||||||
- 모든 페이지에 SEO 태그(Title, Description, Open Graph, Semantic HTML5) 적용.
|
cp .env.example .env
|
||||||
- `npm run build` 타입/린트 오류 0건 및 반응형 UI 검수.
|
|
||||||
|
|
||||||
---
|
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트(APP_PORT) 설정
|
||||||
|
```
|
||||||
|
|
||||||
## 👥 5. 멀티 에이전트 역할 (Multi-Agent Team Protocol)
|
### 2) 컨테이너 이미지 빌드
|
||||||
|
```bash
|
||||||
|
# 통합 단일 컨테이너 (anl-app) 빌드
|
||||||
|
docker compose build
|
||||||
|
|
||||||
| 에이전트 | 담당 역할 (Role) | 주요 임무 (Primary Responsibilities) |
|
# (선택) 캐시 없이 전체 재빌드 시
|
||||||
| :--- | :--- | :--- |
|
docker compose build --no-cache
|
||||||
| 🧠 **Claude** | `planner and reviewer` | 5대 연구 기둥 반영 검토, 데이터 무결성 검증, 최종 코드 PR 승인 |
|
```
|
||||||
| 🎨 **Agy** (Antigravity) | `creator` | Component 개발, `ISL-2026` 및 `multi-agent-orchestration` 연구 데이터 추출 및 코드 마이그레이션 |
|
|
||||||
| 🔍 **Cline** | `reviewer` | UI/UX 반응형 검수, SEO 및 Accessibility 점검, 린트/타입 안전성 검증 |
|
|
||||||
|
|
||||||
> ⚠️ 모든 에이전트는 프로젝트 루트의 [`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)
|
## 🛠️ 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
|
```bash
|
||||||
# 1. 참고용 웹 애플리케이션 디렉터리로 이동
|
|
||||||
cd refer_landing_page
|
cd refer_landing_page
|
||||||
|
|
||||||
# 2. 의존성 패키지 설치
|
# 종속성 설치
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. 개발 서버 실행 (기본 포트: http://localhost:3000)
|
# 개발 서버 실행 (Next.js dev 서버: http://localhost:3000)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 4. 프로덕션 빌드 테스트 (검증 시 사용)
|
# 정적 익스포트 빌드 검증 (out/ 디렉터리 생성)
|
||||||
npm run build
|
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
@@ -4,10 +4,10 @@
|
|||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| **문서 ID** | `REQ-ANL-WEB-001` |
|
| **문서 ID** | `REQ-ANL-WEB-001` |
|
||||||
| **버전** | `2.0` |
|
| **버전** | `2.1` |
|
||||||
| **작성일** | 2026-08-24 |
|
| **작성일** | 2026-08-24 |
|
||||||
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
|
| **작성자** | 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), 지도교수 |
|
| **대상 독자** | 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) |
|
| **선행 문서** | [`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 애플리케이션) 전체 |
|
| **적용 범위** | `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-01 | 콘텐츠 데이터는 페이지 컴포넌트에서 분리해 **`content/` 디렉터리의 타입 지정 모듈**로 이관한다 (`content/members.ts`, `content/publications.ts`, `content/lectures.ts`, `content/standards.ts`) | MUST |
|
||||||
| DM-02 | 각 모듈은 명시적 TypeScript 인터페이스를 export 한다. `any` 사용 금지 | 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 |
|
| DM-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST |
|
||||||
|
|
||||||
### 7.2 스키마 정의 (계약)
|
### 7.2 스키마 정의 (계약)
|
||||||
@@ -563,6 +563,9 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
|||||||
- 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4).
|
- 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4).
|
||||||
|
|
||||||
**R-8.32 [필수 · 게이트 면제 형식 제한] *(v1.13 신설)*** 자동 게이트의 예외는 **`(파일, 행, 사유)` 3-튜플 등재로만** 허용하며, **라인 부분문자열 면제(`if "…" in line: continue`)를 금지**한다. 등재 항목이 실제 소스에 존재하지 않으면(고아 항목) 게이트는 **실패**해야 한다.
|
**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건을 동일 로직에 투입해 **전부 면제됨**을 재현했다.
|
- 근거(실측): `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 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다.
|
- 나아가 `"text-3xl"`·`"Pillar #"` 등의 면제도 **라인 전체 부분문자열 검사**라, 삼항식 한 줄에 두 색이 섞이면 **cobalt 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다.
|
||||||
- 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
|
- 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
|
||||||
@@ -648,7 +651,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
|||||||
**품질 게이트**
|
**품질 게이트**
|
||||||
- [ ] AC-15 `npm run lint` 통과 (경고 0)
|
- [ ] AC-15 `npm run lint` 통과 (경고 0)
|
||||||
- [ ] AC-16 `npx tsc --noEmit` 통과
|
- [ ] 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-18 First Load JS 증가분 ≤ 30 kB
|
||||||
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
|
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
|
||||||
- [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐)
|
- [ ] 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.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` |
|
| 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` |
|
| 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` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.env
|
||||||
@@ -15,5 +15,8 @@ GIN_MODE=release
|
|||||||
# Allowed CORS Origins (Comma separated, or * for all)
|
# Allowed CORS Origins (Comma separated, or * for all)
|
||||||
CORS_ALLOW_ORIGINS=*
|
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)
|
# Automatically run SQL schema migrations on startup (true | false)
|
||||||
AUTO_MIGRATE=true
|
AUTO_MIGRATE=true
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
/export-content
|
/export-content
|
||||||
/api
|
/api
|
||||||
/seed
|
/seed
|
||||||
|
/web/
|
||||||
|
|
||||||
# SQLite databases and WAL journal files
|
# SQLite databases and WAL journal files
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
!db/backup/*.sql
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
*.log
|
||||||
|
|||||||
+25
-30
@@ -1,51 +1,47 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Stage 1: Build the Go Binaries
|
# Stage 1: Build the static frontend export
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
FROM golang:1.26-alpine AS builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
COPY refer_landing_page/package.json refer_landing_page/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY refer_landing_page/ .
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
NODE_ENV=production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Stage 2: Build the Go binaries
|
||||||
|
# ==============================================================================
|
||||||
|
FROM golang:1.26-alpine AS backend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install git and certificates
|
|
||||||
RUN apk add --no-cache git ca-certificates tzdata
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
# Download dependencies
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
COPY backend/ .
|
||||||
# Copy backend source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build pure-Go static binaries
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api ./cmd/api && \
|
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/seed ./cmd/seed && \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/export-content ./cmd/export-content
|
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
|
FROM alpine:3.20
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install ca-certificates and sqlite cli for maintenance
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
|
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
|
||||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
# Create persistent database storage directory
|
|
||||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
|
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
|
||||||
|
|
||||||
# Copy built binaries from builder
|
COPY --from=backend-builder /bin/api /app/bin/api
|
||||||
COPY --from=builder /bin/api /app/bin/api
|
COPY --from=backend-builder /bin/seed /app/bin/seed
|
||||||
COPY --from=builder /bin/seed /app/bin/seed
|
COPY --from=backend-builder /bin/export-content /app/bin/export-content
|
||||||
COPY --from=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 \
|
ENV HOST=0.0.0.0 \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
DB_PATH=/app/data/anl.db \
|
DB_PATH=/app/data/anl.db \
|
||||||
@@ -54,7 +50,6 @@ ENV HOST=0.0.0.0 \
|
|||||||
AUTO_MIGRATE=true
|
AUTO_MIGRATE=true
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
|||||||
@@ -37,5 +37,11 @@ vet:
|
|||||||
test: fmt-check vet
|
test: fmt-check vet
|
||||||
go test -v ./...
|
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:
|
clean:
|
||||||
rm -rf $(BIN_DIR) *.db*
|
rm -rf $(BIN_DIR) *.db*
|
||||||
|
|||||||
+17
-1
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
"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)")
|
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)")
|
dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)")
|
||||||
ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if *portFlag > 0 {
|
if *portFlag > 0 {
|
||||||
@@ -33,6 +37,18 @@ func main() {
|
|||||||
if *ginModeFlag != "" {
|
if *ginModeFlag != "" {
|
||||||
cfg.GinMode = *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)
|
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()
|
addr := cfg.Address()
|
||||||
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
|
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
GinMode string
|
GinMode string
|
||||||
CORSAllowOrigins string
|
CORSAllowOrigins string
|
||||||
AutoMigrate bool
|
AutoMigrate bool
|
||||||
|
AdminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
|
// 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",
|
GinMode: "release",
|
||||||
CORSAllowOrigins: "*",
|
CORSAllowOrigins: "*",
|
||||||
AutoMigrate: true,
|
AutoMigrate: true,
|
||||||
|
AdminToken: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if portStr := os.Getenv("PORT"); portStr != "" {
|
if portStr := os.Getenv("PORT"); portStr != "" {
|
||||||
@@ -91,6 +93,10 @@ func Load() *Config {
|
|||||||
cfg.CORSAllowOrigins = corsStr
|
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 != "" {
|
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||||
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestConfigDefaults(t *testing.T) {
|
|||||||
_ = os.Unsetenv("GIN_MODE")
|
_ = os.Unsetenv("GIN_MODE")
|
||||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||||
|
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
assert.Equal(t, 8080, cfg.Port)
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
@@ -26,6 +27,7 @@ func TestConfigDefaults(t *testing.T) {
|
|||||||
assert.Equal(t, "release", cfg.GinMode)
|
assert.Equal(t, "release", cfg.GinMode)
|
||||||
assert.Equal(t, "*", cfg.CORSAllowOrigins)
|
assert.Equal(t, "*", cfg.CORSAllowOrigins)
|
||||||
assert.True(t, cfg.AutoMigrate)
|
assert.True(t, cfg.AutoMigrate)
|
||||||
|
assert.Equal(t, "", cfg.AdminToken)
|
||||||
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
|
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("GIN_MODE", "debug")
|
||||||
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
|
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
|
||||||
_ = os.Setenv("AUTO_MIGRATE", "false")
|
_ = os.Setenv("AUTO_MIGRATE", "false")
|
||||||
|
_ = os.Setenv("ADMIN_TOKEN", "super-secret-token")
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = os.Unsetenv("PORT")
|
_ = os.Unsetenv("PORT")
|
||||||
_ = os.Unsetenv("HOST")
|
_ = os.Unsetenv("HOST")
|
||||||
@@ -43,6 +46,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
|||||||
_ = os.Unsetenv("GIN_MODE")
|
_ = os.Unsetenv("GIN_MODE")
|
||||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||||
|
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
@@ -52,6 +56,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
|||||||
assert.Equal(t, "debug", cfg.GinMode)
|
assert.Equal(t, "debug", cfg.GinMode)
|
||||||
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
|
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
|
||||||
assert.False(t, cfg.AutoMigrate)
|
assert.False(t, cfg.AutoMigrate)
|
||||||
|
assert.Equal(t, "super-secret-token", cfg.AdminToken)
|
||||||
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
|
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"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"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -23,6 +26,61 @@ func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
|
|||||||
httpx.OK(c, projects)
|
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) {
|
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
||||||
areas, err := h.repo.GetResearchAreas(c.Request.Context())
|
areas, err := h.repo.GetResearchAreas(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,6 +90,61 @@ func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
|||||||
httpx.OK(c, areas)
|
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) {
|
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
|
||||||
stats, err := h.repo.GetStatsSummary(c.Request.Context())
|
stats, err := h.repo.GetStatsSummary(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"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"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validTerms = map[string]bool{
|
||||||
|
"Spring": true,
|
||||||
|
"Fall": true,
|
||||||
|
}
|
||||||
|
|
||||||
type LecturesHandler struct {
|
type LecturesHandler struct {
|
||||||
repo *repository.LecturesRepository
|
repo *repository.LecturesRepository
|
||||||
}
|
}
|
||||||
@@ -22,3 +30,119 @@ func (h *LecturesHandler) GetSemesters(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, semesters)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"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"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
type MembersHandler struct {
|
||||||
repo *repository.MembersRepository
|
repo *repository.MembersRepository
|
||||||
}
|
}
|
||||||
@@ -23,6 +40,69 @@ func (h *MembersHandler) GetMembers(c *gin.Context) {
|
|||||||
httpx.OK(c, members)
|
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) {
|
func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
||||||
alumni, err := h.repo.GetAlumni(c.Request.Context())
|
alumni, err := h.repo.GetAlumni(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,3 +111,66 @@ func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, alumni)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"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"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validPubCategories = map[string]bool{
|
||||||
|
"intl-journal-conf": true,
|
||||||
|
"domestic-journal-conf": true,
|
||||||
|
}
|
||||||
|
|
||||||
type PublicationsHandler struct {
|
type PublicationsHandler struct {
|
||||||
repo *repository.PublicationsRepository
|
repo *repository.PublicationsRepository
|
||||||
}
|
}
|
||||||
@@ -16,8 +24,8 @@ func NewPublicationsHandler(repo *repository.PublicationsRepository) *Publicatio
|
|||||||
|
|
||||||
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||||
category := c.Query("category")
|
category := c.Query("category")
|
||||||
if category != "" && category != "intl-journal-conf" && category != "domestic-journal-conf" {
|
if category != "" && !validPubCategories[category] {
|
||||||
httpx.BadRequest(c, "Invalid category parameter: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,13 +37,76 @@ func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
|||||||
httpx.OK(c, pubs)
|
httpx.OK(c, pubs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
func (h *PublicationsHandler) CreatePublication(c *gin.Context) {
|
||||||
highlights, err := h.repo.GetHighlights(c.Request.Context())
|
var req models.Publication
|
||||||
if err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
httpx.InternalServerError(c, "Failed to retrieve highlights: "+err.Error())
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
return
|
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) {
|
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
||||||
@@ -46,3 +117,58 @@ func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, patents)
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"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"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
type StandardizationHandler struct {
|
||||||
repo *repository.StandardizationRepository
|
repo *repository.StandardizationRepository
|
||||||
}
|
}
|
||||||
@@ -22,3 +39,135 @@ func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, bodies)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func BadRequest(c *gin.Context, msg string) {
|
||||||
Error(c, http.StatusBadRequest, msg)
|
Error(c, http.StatusBadRequest, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var projects []models.ResearchProject
|
projects := make([]models.ResearchProject, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.ResearchProject
|
var p models.ResearchProject
|
||||||
var kwJSON string
|
var kwJSON string
|
||||||
@@ -55,6 +55,54 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
|||||||
return projects, nil
|
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) {
|
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name_en, name_kr, display_order
|
SELECT id, name_en, name_kr, display_order
|
||||||
@@ -67,7 +115,7 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var areas []models.ResearchArea
|
areas := make([]models.ResearchArea, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.ResearchArea
|
var a models.ResearchArea
|
||||||
if err := rows.Scan(&a.ID, &a.NameEn, &a.NameKr, &a.DisplayOrder); err != nil {
|
if err := rows.Scan(&a.ID, &a.NameEn, &a.NameKr, &a.DisplayOrder); err != nil {
|
||||||
@@ -78,6 +126,166 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
|||||||
return areas, rows.Err()
|
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) {
|
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
|
||||||
var summary models.StatsSummary
|
var summary models.StatsSummary
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func NewLecturesRepository(db *sql.DB) *LecturesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
||||||
// Query semesters ordered by year DESC and Fall before Spring within same year
|
|
||||||
semQuery := `
|
semQuery := `
|
||||||
SELECT id, year, term
|
SELECT id, year, term
|
||||||
FROM semesters
|
FROM semesters
|
||||||
@@ -29,8 +28,8 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
|||||||
}
|
}
|
||||||
defer semRows.Close()
|
defer semRows.Close()
|
||||||
|
|
||||||
var semesters []models.SemesterWithCourses
|
semesters := make([]models.SemesterWithCourses, 0)
|
||||||
semMap := make(map[int]int) // semester_id -> index in slice
|
semMap := make(map[int]int)
|
||||||
|
|
||||||
for semRows.Next() {
|
for semRows.Next() {
|
||||||
var s models.SemesterWithCourses
|
var s models.SemesterWithCourses
|
||||||
@@ -45,7 +44,6 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query courses ordered by display_order ASC, id ASC
|
|
||||||
courseQuery := `
|
courseQuery := `
|
||||||
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
||||||
FROM courses
|
FROM courses
|
||||||
@@ -69,3 +67,83 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
|||||||
|
|
||||||
return semesters, courseRows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var members []models.Member
|
members := make([]models.Member, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m models.Member
|
var m models.Member
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -42,6 +42,46 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
|||||||
return members, rows.Err()
|
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) {
|
func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
var alumni []models.Alumnus
|
alumni := make([]models.Alumnus, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.Alumnus
|
var a models.Alumnus
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -67,3 +107,43 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
|
|||||||
}
|
}
|
||||||
return alumni, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var pubs []models.Publication
|
pubs := make([]models.Publication, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Publication
|
var p models.Publication
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -48,6 +48,46 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
|||||||
return pubs, rows.Err()
|
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) {
|
func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
var pubs []models.Publication
|
pubs := make([]models.Publication, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Publication
|
var p models.Publication
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -87,7 +127,7 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var patents []models.Patent
|
patents := make([]models.Patent, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Patent
|
var p models.Patent
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -100,3 +140,43 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
|||||||
}
|
}
|
||||||
return patents, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
||||||
// Query bodies ordered by display_order
|
|
||||||
bodyQuery := `
|
bodyQuery := `
|
||||||
SELECT id, org, full_name, scope, period, role, display_order
|
SELECT id, org, full_name, scope, period, role, display_order
|
||||||
FROM standards_bodies
|
FROM standards_bodies
|
||||||
@@ -29,8 +28,8 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
}
|
}
|
||||||
defer bodyRows.Close()
|
defer bodyRows.Close()
|
||||||
|
|
||||||
var bodies []models.StandardsBodyWithProjects
|
bodies := make([]models.StandardsBodyWithProjects, 0)
|
||||||
bodyMap := make(map[int]int) // body_id -> index in bodies
|
bodyMap := make(map[int]int)
|
||||||
|
|
||||||
for bodyRows.Next() {
|
for bodyRows.Next() {
|
||||||
var b models.StandardsBodyWithProjects
|
var b models.StandardsBodyWithProjects
|
||||||
@@ -45,7 +44,6 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query documents ordered by body_id, and preserves curated insertion order with MIN(id) ASC
|
|
||||||
docQuery := `
|
docQuery := `
|
||||||
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
||||||
FROM standard_documents
|
FROM standard_documents
|
||||||
@@ -57,13 +55,12 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
}
|
}
|
||||||
defer docRows.Close()
|
defer docRows.Close()
|
||||||
|
|
||||||
// Group documents into projects under each body preserving curated order
|
|
||||||
type projectKey struct {
|
type projectKey struct {
|
||||||
bodyID int
|
bodyID int
|
||||||
wg string
|
wg string
|
||||||
projectName string
|
projectName string
|
||||||
}
|
}
|
||||||
projectMap := make(map[projectKey]int) // projectKey -> index in bodies[bIdx].Projects
|
projectMap := make(map[projectKey]int)
|
||||||
|
|
||||||
for docRows.Next() {
|
for docRows.Next() {
|
||||||
var d models.StandardDocument
|
var d models.StandardDocument
|
||||||
@@ -93,3 +90,83 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
|
|
||||||
return bodies, docRows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package router
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/handlers"
|
"git.godopu.com/lab/landing_page/backend/internal/handlers"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||||
@@ -10,7 +13,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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()
|
r := gin.Default()
|
||||||
|
|
||||||
allowOrigin := "*"
|
allowOrigin := "*"
|
||||||
@@ -44,7 +47,7 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
|||||||
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
||||||
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
||||||
|
|
||||||
// API v1 Group
|
// API v1 Public Group (Read-only)
|
||||||
v1 := r.Group("/api/v1")
|
v1 := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
v1.GET("/health", func(c *gin.Context) {
|
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)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package router_test
|
package router_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||||
@@ -18,8 +20,9 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testAdminToken = "test-secret-token"
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
// In-memory or temporary file sqlite DB
|
|
||||||
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
||||||
database, err := db.Connect(tmpDB)
|
database, err := db.Connect(tmpDB)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -27,7 +30,6 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
err = db.RunMigrations(database)
|
err = db.RunMigrations(database)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Seed test data from ../../seed/data
|
|
||||||
seedDir := filepath.Join("..", "..", "seed", "data")
|
seedDir := filepath.Join("..", "..", "seed", "data")
|
||||||
seedDatabase(t, database, seedDir)
|
seedDatabase(t, database, seedDir)
|
||||||
|
|
||||||
@@ -65,45 +67,75 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
|||||||
// 2. Areas
|
// 2. Areas
|
||||||
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
||||||
require.NoError(t, err)
|
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))
|
require.NoError(t, json.Unmarshal(aData, &areas))
|
||||||
for _, a := range areas {
|
for idx, a := range areas {
|
||||||
_, err := tx.Exec(`INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?)`, a.NameEn, a.NameKr, a.DisplayOrder)
|
_, err := tx.Exec(`
|
||||||
|
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, a.En, a.Kr, idx+1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Members
|
// 3. Members
|
||||||
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
||||||
require.NoError(t, err)
|
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))
|
require.NoError(t, json.Unmarshal(mData, &members))
|
||||||
for _, m := range members {
|
for idx, m := range members {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
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)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Alumni
|
// 4. Alumni
|
||||||
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
||||||
require.NoError(t, err)
|
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))
|
require.NoError(t, json.Unmarshal(alumniData, &alumni))
|
||||||
for _, a := range alumni {
|
for _, a := range alumni {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
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)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Publications
|
// 5. Publications
|
||||||
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var pubs []models.Publication
|
var publications []struct {
|
||||||
require.NoError(t, json.Unmarshal(pubData, &pubs))
|
Category string `json:"category"`
|
||||||
for _, p := range pubs {
|
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(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -114,17 +146,26 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
|||||||
// 6. Patents
|
// 6. Patents
|
||||||
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
||||||
require.NoError(t, err)
|
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))
|
require.NoError(t, json.Unmarshal(patData, &patents))
|
||||||
for _, pat := range patents {
|
for _, p := range patents {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
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)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Semesters & Courses
|
// 7. Lectures
|
||||||
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var semesters []struct {
|
var semesters []struct {
|
||||||
@@ -200,247 +241,327 @@ type APIResponse[T any] struct {
|
|||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder {
|
func ptrString(s string) *string {
|
||||||
req := httptest.NewRequest(method, target, nil)
|
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()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthEndpoint(t *testing.T) {
|
func TestHealthCheck(t *testing.T) {
|
||||||
database := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer database.Close()
|
defer db.Close()
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
r := router.SetupRouter(database)
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/health")
|
|
||||||
|
|
||||||
|
w := doRequest(r, "GET", "/api/v1/health", nil, "")
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "ok", resp.Data["status"])
|
assert.Equal(t, 2, courseCount)
|
||||||
assert.Equal(t, "anl-backend-api", resp.Data["service"])
|
|
||||||
|
// 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) {
|
func TestStaticServingAndRedirects(t *testing.T) {
|
||||||
database := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer database.Close()
|
defer db.Close()
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
// 1. /intro 308 permanent redirect to /
|
||||||
w := executeRequest(r, "GET", "/api/v1/research-projects")
|
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)
|
// 2. Unknown API route returns 404 JSON (not HTML)
|
||||||
var resp APIResponse[[]models.ResearchProject]
|
wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "")
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
assert.Equal(t, http.StatusNotFound, wAPI404.Code)
|
||||||
assert.Len(t, resp.Data, 3)
|
var apiErr map[string]any
|
||||||
assert.Equal(t, "iot-standards", resp.Data[0].Slug)
|
require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr))
|
||||||
assert.Equal(t, "AIoT & AI-RAN", resp.Data[0].Title)
|
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) {
|
func strconvItoa(i int) string {
|
||||||
database := setupTestDB(t)
|
return strconv.Itoa(i)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-6
@@ -1,12 +1,16 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
anl-backend:
|
anl-app:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
container_name: anl-backend-api
|
args:
|
||||||
|
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-}
|
||||||
|
container_name: anl-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${APP_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
@@ -14,13 +18,15 @@ services:
|
|||||||
- GIN_MODE=release
|
- GIN_MODE=release
|
||||||
- CORS_ALLOW_ORIGINS=*
|
- CORS_ALLOW_ORIGINS=*
|
||||||
- AUTO_MIGRATE=true
|
- AUTO_MIGRATE=true
|
||||||
|
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set -- see .env.example}
|
||||||
volumes:
|
volumes:
|
||||||
- anl-db-data:/app/data
|
- anl-db-data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"]
|
||||||
interval: 30s
|
interval: 15s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
anl-db-data:
|
anl-db-data:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
import type { ResearchProject } from "../../content/types";
|
import type { ResearchProject } from "../../content/types";
|
||||||
import ProjectPageViewControls from "./ProjectPageViewControls";
|
import ProjectPageViewControls from "./ProjectPageViewControls";
|
||||||
|
|
||||||
@@ -13,22 +14,24 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
<Reveal variant="up">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div>
|
||||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||||
Key Initiatives
|
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||||
</span>
|
Key Initiatives
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||||
|
Research Projects
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
<p className="text-xs text-ink-mute font-mono">
|
||||||
Research Projects
|
{projects.length} core international standardization research projects
|
||||||
</h2>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink-mute font-mono">
|
</Reveal>
|
||||||
{projects.length} core international standardization research projects
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
|
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
|
||||||
<div
|
<div
|
||||||
@@ -40,13 +43,16 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
|||||||
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-cobalt/50 rounded"
|
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-cobalt/50 rounded"
|
||||||
>
|
>
|
||||||
{projects.map((proj, idx) => (
|
{projects.map((proj, idx) => (
|
||||||
<article
|
<Reveal
|
||||||
key={proj.slug}
|
key={proj.slug}
|
||||||
|
as="article"
|
||||||
id={`project-${proj.slug}`}
|
id={`project-${proj.slug}`}
|
||||||
role="group"
|
role="group"
|
||||||
aria-roledescription="slide"
|
aria-roledescription="slide"
|
||||||
aria-label={`${projects.length}개 중 ${idx + 1}번째 프로젝트`}
|
aria-label={`${projects.length}개 중 ${idx + 1}번째 프로젝트`}
|
||||||
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-cobalt/60 transition-colors space-y-5 break-words [word-break:keep-all]"
|
variant="up"
|
||||||
|
delay={Math.min(idx, 6) * 60}
|
||||||
|
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-cobalt/60 transition-colors space-y-5 break-words [word-break:keep-all] h-full"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header Info */}
|
{/* Header Info */}
|
||||||
@@ -124,7 +130,7 @@ export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
← 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 "+ Add New Semester" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>→</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 "+ Add Standards Body" 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}</>;
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import React from "react";
|
"use client";
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
import { getSemesters } from "../../lib/api";
|
import { getSemesters } from "../../lib/api";
|
||||||
|
import type { Semester } from "../../content/types";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export default function LecturesPage() {
|
||||||
title: "Lectures",
|
const [semesters, setSemesters] = useState<Semester[]>([]);
|
||||||
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
useEffect(() => {
|
||||||
|
getSemesters().then((s) => {
|
||||||
export default async function LecturesPage() {
|
if (s) setSemesters(s);
|
||||||
const semesters = (await getSemesters()) ?? [];
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const recentSemesters = semesters.slice(0, 3);
|
const recentSemesters = semesters.slice(0, 3);
|
||||||
const olderSemesters = semesters.slice(3);
|
const olderSemesters = semesters.slice(3);
|
||||||
@@ -22,110 +24,124 @@ export default async function LecturesPage() {
|
|||||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<Reveal variant="up">
|
||||||
LECTURES
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
</h1>
|
LECTURES
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Recent 3 Semesters */}
|
{/* Recent 3 Semesters */}
|
||||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||||
<div className="flex justify-between items-center">
|
<Reveal variant="up">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<div className="flex justify-between items-center">
|
||||||
Recent Courses
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
</h2>
|
Recent Courses
|
||||||
<span className="font-mono text-xs text-ink-mute hidden sm:inline">
|
</h2>
|
||||||
Latest 3 Semesters
|
<span className="font-mono text-xs text-ink-mute hidden sm:inline">
|
||||||
</span>
|
Latest 3 Semesters
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-6">
|
||||||
{recentSemesters.map((sem, idx) => (
|
{recentSemesters.map((sem, idx) => (
|
||||||
<div
|
<Reveal
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm"
|
variant="up"
|
||||||
|
delay={idx * 80}
|
||||||
|
className="h-full"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<div className="flex justify-between items-center border-b border-line pb-2.5">
|
className="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm h-full"
|
||||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
>
|
||||||
{sem.term} {sem.year}
|
<div className="space-y-3">
|
||||||
</span>
|
<div className="flex justify-between items-center border-b border-line pb-2.5">
|
||||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10">
|
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||||
Latest
|
{sem.term} {sem.year}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10">
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2.5 pt-1">
|
||||||
|
{sem.courses.map((c, cIdx) => (
|
||||||
|
<li key={cIdx} className="space-y-0.5">
|
||||||
|
<div className="font-serif text-sm sm:text-base text-ink leading-snug">
|
||||||
|
{c.en}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-ink-mute">
|
||||||
|
Code: {c.code} {c.note && `(${c.note})`}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-2.5 pt-1">
|
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
|
||||||
{sem.courses.map((c, cIdx) => (
|
<span>Total Offerings</span>
|
||||||
<li key={cIdx} className="space-y-0.5">
|
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
||||||
<div className="font-serif text-sm sm:text-base text-ink leading-snug">
|
</div>
|
||||||
{c.en}
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs text-ink-mute">
|
|
||||||
Code: {c.code} {c.note && `(${c.note})`}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
|
|
||||||
<span>Total Offerings</span>
|
|
||||||
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Older Semesters (Native <details>/<summary> progressive disclosure) */}
|
{/* Older Semesters (Native <details>/<summary> progressive disclosure) */}
|
||||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||||
<details className="group border border-line rounded-lg bg-paper p-5 sm:p-6 transition-all duration-200">
|
<Reveal variant="up">
|
||||||
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
<div className="border border-line rounded-lg bg-paper p-5 sm:p-6 transition-all duration-200">
|
||||||
<div className="space-y-1">
|
<details className="group">
|
||||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
||||||
Browse all semesters
|
<div className="space-y-1">
|
||||||
</span>
|
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||||
<p className="text-xs text-ink-mute font-mono">
|
Browse all semesters
|
||||||
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
|
|
||||||
<span className="group-open:hidden">Open ▾</span>
|
|
||||||
<span className="hidden group-open:inline">Close ▴</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
|
||||||
{olderSemesters.map((sem, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
|
|
||||||
<span>
|
|
||||||
{sem.term} {sem.year}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-xs text-ink-mute">
|
|
||||||
{sem.courses.length} Courses
|
|
||||||
</span>
|
</span>
|
||||||
|
<p className="text-xs text-ink-mute font-mono">
|
||||||
|
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
|
||||||
|
<span className="group-open:hidden">Open ▾</span>
|
||||||
|
<span className="hidden group-open:inline">Close ▴</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
<ul className="space-y-2">
|
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||||||
{sem.courses.map((c, cIdx) => (
|
{olderSemesters.map((sem, idx) => (
|
||||||
<li key={cIdx} className="space-y-0.5">
|
<div
|
||||||
<div className="font-serif text-sm text-ink leading-snug">
|
key={idx}
|
||||||
{c.en}
|
className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
|
||||||
</div>
|
>
|
||||||
<div className="font-mono text-[0.7rem] text-ink-mute">
|
<div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
|
||||||
Code: {c.code} {c.note && `(${c.note})`}
|
<span>
|
||||||
</div>
|
{sem.term} {sem.year}
|
||||||
</li>
|
</span>
|
||||||
))}
|
<span className="font-mono text-xs text-ink-mute">
|
||||||
</ul>
|
{sem.courses.length} Courses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sem.courses.map((c, cIdx) => (
|
||||||
|
<li key={cIdx} className="space-y-0.5">
|
||||||
|
<div className="font-serif text-sm text-ink leading-snug">
|
||||||
|
{c.en}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[0.7rem] text-ink-mute">
|
||||||
|
Code: {c.code} {c.note && `(${c.note})`}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Members",
|
||||||
|
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MembersLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import React from "react";
|
"use client";
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
import { getMembers, getAlumni } from "../../lib/api";
|
import { getMembers, getAlumni } from "../../lib/api";
|
||||||
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
|
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
|
||||||
|
import type { Member, Alumnus } from "../../content/types";
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Members",
|
|
||||||
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const DEGREE_LABEL: Record<string, string> = {
|
const DEGREE_LABEL: Record<string, string> = {
|
||||||
Professor: "Professor",
|
Professor: "Professor",
|
||||||
@@ -20,9 +17,18 @@ const DEGREE_LABEL: Record<string, string> = {
|
|||||||
MS: "M. S.",
|
MS: "M. S.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function MembersPage() {
|
export default function MembersPage() {
|
||||||
const activeMembers = (await getMembers()) ?? [];
|
const [activeMembers, setActiveMembers] = useState<Member[]>([]);
|
||||||
const alumni = (await getAlumni()) ?? [];
|
const [alumni, setAlumni] = useState<Alumnus[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMembers().then((m) => {
|
||||||
|
if (m) setActiveMembers(m);
|
||||||
|
});
|
||||||
|
getAlumni().then((a) => {
|
||||||
|
if (a) setAlumni(a);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const phdStudents = activeMembers.filter(
|
const phdStudents = activeMembers.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
@@ -38,121 +44,147 @@ export default async function MembersPage() {
|
|||||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<Reveal variant="up">
|
||||||
LAB MEMBERS
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
</h1>
|
LAB MEMBERS
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Professor Section */}
|
{/* Professor Section */}
|
||||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||||
<div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
<Reveal variant="up">
|
||||||
<div className="lg:col-span-8 space-y-4">
|
<div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||||
<h2 className="font-serif text-3xl font-normal text-ink">
|
<div className="lg:col-span-8 space-y-4">
|
||||||
Prof. Seok-Joo Koh
|
<h2 className="font-serif text-3xl font-normal text-ink">
|
||||||
</h2>
|
Prof. Seok-Joo Koh
|
||||||
<div className="text-sm text-ink-mute leading-relaxed space-y-2">
|
</h2>
|
||||||
<p>
|
<div className="text-sm text-ink-mute leading-relaxed space-y-2">
|
||||||
Professor at the School of Computer Science and Engineering, Kyungpook National University, and Dean of the College of IT Engineering. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP & SKILL architectures, and multi-agent orchestration, and serves as Project Editor in ISO/IEC JTC1, ITU-T, and IEC TC100.
|
<p>
|
||||||
</p>
|
Professor at the School of Computer Science and Engineering, Kyungpook National University, and Dean of the College of IT Engineering. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP & SKILL architectures, and multi-agent orchestration, and serves as Project Editor in ISO/IEC JTC1, ITU-T, and IEC TC100.
|
||||||
<div>
|
</p>
|
||||||
<ProfessorDetailsDialog />
|
<div>
|
||||||
|
<ProfessorDetailsDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono">
|
||||||
|
<a
|
||||||
|
href="mailto:sjkoh@knu.ac.kr"
|
||||||
|
className="text-cobalt-dark hover:underline font-bold"
|
||||||
|
>
|
||||||
|
sjkoh@knu.ac.kr
|
||||||
|
</a>
|
||||||
|
<span className="text-ink-mute">|</span>
|
||||||
|
<a
|
||||||
|
href="https://github.com/sjkoh12"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-ink hover:text-cobalt-dark hover:underline"
|
||||||
|
>
|
||||||
|
GitHub Profile ↗
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono">
|
<div className="lg:col-span-4 flex justify-center items-center">
|
||||||
<a
|
<div className="relative w-48 h-48 sm:w-56 sm:h-56 md:w-60 md:h-60 rounded-xl overflow-hidden border border-line bg-transparent">
|
||||||
href="mailto:sjkoh@knu.ac.kr"
|
<Image
|
||||||
className="text-cobalt-dark hover:underline font-bold"
|
src="/images/prof_profile.png"
|
||||||
>
|
alt="Prof. Seok-Joo Koh"
|
||||||
sjkoh@knu.ac.kr
|
fill
|
||||||
</a>
|
sizes="(max-width: 1024px) 224px, 240px"
|
||||||
<span className="text-ink-mute">|</span>
|
className="object-cover object-top"
|
||||||
<a
|
priority
|
||||||
href="https://github.com/sjkoh12"
|
/>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-ink hover:text-cobalt-dark hover:underline"
|
|
||||||
>
|
|
||||||
GitHub Profile ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-4 bg-ivory p-6 rounded border border-line text-xs space-y-2 font-mono">
|
</Reveal>
|
||||||
<div className="text-ink font-bold border-b border-line pb-1">
|
|
||||||
Affiliation & Office
|
|
||||||
</div>
|
|
||||||
<p className="text-ink-mute">Kyungpook National University</p>
|
|
||||||
<p className="text-ink-mute">IT Building #5 Room 527</p>
|
|
||||||
<p className="text-ink-mute">Daegu, Republic of Korea</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Active Researchers Section */}
|
{/* Active Researchers Section */}
|
||||||
<section className="space-y-8 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-8 pt-6 md:pt-8 border-t border-line">
|
||||||
<div className="flex justify-between items-end">
|
<Reveal variant="up">
|
||||||
<div>
|
<div className="flex justify-between items-end">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<div>
|
||||||
Active Researchers
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
</h2>
|
Active Researchers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
|
|
||||||
{/* Ph.D. Candidates / Students */}
|
{/* Ph.D. Candidates / Students */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
<Reveal variant="up">
|
||||||
Ph.D. Course ({phdStudents.length})
|
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||||
</h3>
|
Ph.D. Course ({phdStudents.length})
|
||||||
|
</h3>
|
||||||
|
</Reveal>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{phdStudents.map((mem, idx) => (
|
{phdStudents.map((mem, idx) => (
|
||||||
<div
|
<Reveal
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors"
|
variant="up"
|
||||||
|
delay={Math.min(idx, 6) * 60}
|
||||||
|
className="h-full"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div
|
||||||
<span className="font-serif text-lg font-normal text-ink">
|
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
|
||||||
{mem.ko}
|
>
|
||||||
</span>
|
<div className="flex justify-between items-baseline">
|
||||||
<span className="font-mono text-xs text-cobalt-dark">
|
<span className="font-serif text-lg font-normal text-ink">
|
||||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
{mem.ko}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="font-mono text-xs text-cobalt-dark">
|
||||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||||
{mem.affiliation && (
|
</span>
|
||||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
|
||||||
with {mem.affiliation}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||||
</div>
|
{mem.affiliation && (
|
||||||
|
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||||
|
with {mem.affiliation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* M.S. Candidates / Students */}
|
{/* M.S. Candidates / Students */}
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
<Reveal variant="up">
|
||||||
M.S. Course ({msStudents.length})
|
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||||
</h3>
|
M.S. Course ({msStudents.length})
|
||||||
|
</h3>
|
||||||
|
</Reveal>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{msStudents.map((mem, idx) => (
|
{msStudents.map((mem, idx) => (
|
||||||
<div
|
<Reveal
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors"
|
variant="up"
|
||||||
|
delay={Math.min(idx, 6) * 60}
|
||||||
|
className="h-full"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div
|
||||||
<span className="font-serif text-lg font-normal text-ink">
|
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
|
||||||
{mem.ko}
|
>
|
||||||
</span>
|
<div className="flex justify-between items-baseline">
|
||||||
<span className="font-mono text-xs text-cobalt-dark">
|
<span className="font-serif text-lg font-normal text-ink">
|
||||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
{mem.ko}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="font-mono text-xs text-cobalt-dark">
|
||||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||||
{mem.affiliation && (
|
</span>
|
||||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
|
||||||
with {mem.affiliation}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||||
</div>
|
{mem.affiliation && (
|
||||||
|
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||||
|
with {mem.affiliation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,13 +192,15 @@ export default async function MembersPage() {
|
|||||||
|
|
||||||
{/* Alumni Section (Native <details>/<summary> progressive disclosure) */}
|
{/* Alumni Section (Native <details>/<summary> progressive disclosure) */}
|
||||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||||
<div className="flex justify-between items-end">
|
<Reveal variant="up">
|
||||||
<div>
|
<div className="flex justify-between items-end">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
<div>
|
||||||
Graduates
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||||
</h2>
|
Graduates
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
|
|
||||||
<details className="group border border-line rounded-lg bg-paper p-6 transition-all duration-200">
|
<details className="group border border-line rounded-lg bg-paper p-6 transition-all duration-200">
|
||||||
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
||||||
|
|||||||
+145
-109
@@ -1,10 +1,13 @@
|
|||||||
import React from "react";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
|
import Counter from "@/components/Counter";
|
||||||
import HeroComposition from "./_components/HeroComposition";
|
import HeroComposition from "./_components/HeroComposition";
|
||||||
import { ProjectPageView } from "./_components/ProjectPageView";
|
import { ProjectPageView } from "./_components/ProjectPageView";
|
||||||
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
|
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
|
||||||
|
import type { ResearchProject } from "../content/types";
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const INTERNATIONAL_SDOS = [
|
const INTERNATIONAL_SDOS = [
|
||||||
"IEC TC100: Multimedia Systems and Equipment",
|
"IEC TC100: Multimedia Systems and Equipment",
|
||||||
@@ -14,71 +17,92 @@ const INTERNATIONAL_SDOS = [
|
|||||||
"ISO/IEC JTC1/SC6: Reliable Multicasting",
|
"ISO/IEC JTC1/SC6: Reliable Multicasting",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default function HomePage() {
|
||||||
const stats = (await getStatsSummary()) ?? {
|
const [stats, setStats] = useState({
|
||||||
intlPubsCount: 0,
|
intlPubsCount: 0,
|
||||||
patentCount: 0,
|
patentCount: 0,
|
||||||
stdDocsCount: 0,
|
stdDocsCount: 0,
|
||||||
};
|
});
|
||||||
|
const [researchAreas, setResearchAreas] = useState<string[]>([]);
|
||||||
|
const [researchProjects, setResearchProjects] = useState<ResearchProject[]>([]);
|
||||||
|
|
||||||
const researchAreas = (await getResearchAreaNames()) ?? [];
|
useEffect(() => {
|
||||||
const researchProjects = (await getResearchProjects()) ?? [];
|
getStatsSummary().then((s) => {
|
||||||
|
if (s) setStats(s);
|
||||||
|
});
|
||||||
|
getResearchAreaNames().then((areas) => {
|
||||||
|
if (areas) setResearchAreas(areas);
|
||||||
|
});
|
||||||
|
getResearchProjects().then((projs) => {
|
||||||
|
if (projs) setResearchProjects(projs);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
|
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative">
|
<section className="relative">
|
||||||
<div className="mb-4">
|
<Reveal variant="up">
|
||||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
<div className="mb-4">
|
||||||
Kyungpook National University · AI Agent Networking Lab (ANL)
|
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||||
</span>
|
Kyungpook National University · AI Agent Networking Lab (ANL)
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:items-center">
|
||||||
<div className="lg:col-span-7 space-y-6 lg:pt-4">
|
<div className="lg:col-span-7 space-y-6 lg:pt-4">
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
<Reveal variant="up">
|
||||||
Multi-Agent <br />
|
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
||||||
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
Multi-Agent <br />
|
||||||
</h1>
|
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
||||||
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl">
|
</h1>
|
||||||
The AI Agent Networking Laboratory (ANL) studies agent networking technologies and standard frameworks that enable AI agents to communicate and collaborate with one another for multi-agent orchestration.
|
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl mt-4">
|
||||||
</p>
|
The AI Agent Networking Laboratory (ANL) studies agent networking technologies and standard frameworks that enable AI agents to communicate and collaborate with one another for multi-agent orchestration.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* Metrics Row (Derived from real content data) */}
|
{/* Metrics Row (Derived from real content data) */}
|
||||||
<div className="pt-6 border-t border-line grid grid-cols-3 gap-4">
|
<div className="pt-6 border-t border-line grid grid-cols-3 gap-4">
|
||||||
<div>
|
<Reveal variant="up" delay={80}>
|
||||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
<div>
|
||||||
{stats.intlPubsCount}
|
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||||
|
<Counter value={stats.intlPubsCount} />
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||||
|
International Journals & Conference
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
</Reveal>
|
||||||
International Journals & Conference
|
<Reveal variant="up" delay={160}>
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||||
|
<Counter value={stats.stdDocsCount} />
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||||
|
Standardization Contributions
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
<div>
|
<Reveal variant="up" delay={240}>
|
||||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
<div>
|
||||||
{stats.stdDocsCount}
|
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||||
|
<Counter value={stats.patentCount} />
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||||
|
Patent<br />
|
||||||
|
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
|
||||||
|
(IoT Architecture & Protocols)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
</Reveal>
|
||||||
Standardization Contributions
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
|
||||||
{stats.patentCount}
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
|
||||||
Patent<br />
|
|
||||||
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
|
|
||||||
(IoT Architecture & Protocols)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-[500px] mx-auto my-[30px] lg:my-0 lg:max-w-none lg:mx-0 lg:col-span-5 border border-line bg-paper p-4 rounded-lg">
|
<Reveal variant="scale" delay={120} className="w-full max-w-[500px] mx-auto my-[30px] lg:my-0 lg:max-w-none lg:mx-0 lg:col-span-5 border border-line bg-paper p-4 rounded-lg">
|
||||||
<HeroComposition />
|
<HeroComposition />
|
||||||
</div>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -87,22 +111,24 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
{/* 14 Research Areas Grid */}
|
{/* 14 Research Areas Grid */}
|
||||||
<section className="space-y-8 pt-16 md:pt-20 border-t border-line">
|
<section className="space-y-8 pt-16 md:pt-20 border-t border-line">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
<Reveal variant="up">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div>
|
||||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||||
Core Domains
|
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||||
</span>
|
Core Domains
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||||
|
Research Areas
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
<p className="text-xs text-ink-mute font-mono">
|
||||||
Research Areas
|
{researchAreas.length} fundamental networking protocols and AI system domains
|
||||||
</h2>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink-mute font-mono">
|
</Reveal>
|
||||||
{researchAreas.length} fundamental networking protocols and AI system domains
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{researchAreas.map((area, idx) => {
|
{researchAreas.map((area, idx) => {
|
||||||
@@ -110,28 +136,34 @@ export default async function HomePage() {
|
|||||||
const isOdd = (idx + 1) % 2 !== 0;
|
const isOdd = (idx + 1) % 2 !== 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Reveal
|
||||||
key={no}
|
key={no}
|
||||||
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
|
variant="up"
|
||||||
|
delay={Math.min(idx, 6) * 60}
|
||||||
|
className="h-full"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<div className="flex justify-between items-center">
|
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
|
||||||
<span
|
>
|
||||||
className={`font-mono text-xs font-bold ${
|
<div className="space-y-3">
|
||||||
isOdd ? "text-vermillion-dark" : "text-cobalt-dark"
|
<div className="flex justify-between items-center">
|
||||||
}`}
|
<span
|
||||||
>
|
className={`font-mono text-xs font-bold ${
|
||||||
{no}
|
isOdd ? "text-vermillion-dark" : "text-cobalt-dark"
|
||||||
</span>
|
}`}
|
||||||
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider">
|
>
|
||||||
Topic #{no}
|
{no}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider">
|
||||||
|
Topic #{no}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||||
|
{area}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
|
||||||
{area}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,43 +171,47 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
{/* International Standardizations Section */}
|
{/* International Standardizations Section */}
|
||||||
<section className="space-y-6 pt-16 md:pt-20 border-t border-line">
|
<section className="space-y-6 pt-16 md:pt-20 border-t border-line">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
<Reveal variant="up">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div>
|
||||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||||
Global Engagement
|
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||||
</span>
|
Global Engagement
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||||
|
International Standardizations
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
|
||||||
International Standardizations
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
|
|
||||||
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6">
|
<Reveal variant="up">
|
||||||
<p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl">
|
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6">
|
||||||
Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations):
|
<p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl">
|
||||||
</p>
|
Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations):
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2">
|
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2">
|
||||||
{INTERNATIONAL_SDOS.map((sdo, idx) => (
|
{INTERNATIONAL_SDOS.map((sdo, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans">
|
<li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans">
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||||
<span>{sdo}</span>
|
<span>{sdo}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-line/60">
|
<div className="pt-4 border-t border-line/60">
|
||||||
<Link
|
<Link
|
||||||
href="/standardization"
|
href="/standardization"
|
||||||
className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline"
|
className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline"
|
||||||
>
|
>
|
||||||
<span>Please refer to here for details →</span>
|
<span>Please refer to here for details →</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
|
import { CategoryNav } from "../../_components/CategoryNav";
|
||||||
|
import { getPublications, getPatents } from "../../../../lib/api";
|
||||||
|
import { PUB_CATEGORIES } from "../../../../content/categories";
|
||||||
|
import type { PubCategory, Publication, Patent } from "../../../../content/types";
|
||||||
|
|
||||||
|
export function PublicationCategoryClient({ category }: { category: string }) {
|
||||||
|
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||||
|
if (!currentCat) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = category as PubCategory;
|
||||||
|
|
||||||
|
const [publications, setPublications] = useState<Publication[]>([]);
|
||||||
|
const [patents, setPatents] = useState<Patent[]>([]);
|
||||||
|
|
||||||
|
const [selectedYear, setSelectedYear] = useState<string>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublications().then((p) => {
|
||||||
|
if (p) setPublications(p);
|
||||||
|
});
|
||||||
|
getPatents().then((pat) => {
|
||||||
|
if (pat) setPatents(pat);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||||
|
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
|
||||||
|
patent: patents.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
let allRecords: any[] = [];
|
||||||
|
if (slug === "patent") {
|
||||||
|
allRecords = patents.map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
inventors: p.inventors,
|
||||||
|
appNo: p.applicationNo,
|
||||||
|
appDate: p.applicationAt,
|
||||||
|
regNo: p.registrationNo,
|
||||||
|
regDate: p.registrationAt,
|
||||||
|
country: p.country,
|
||||||
|
publishedAt: p.publishedAt,
|
||||||
|
isPatent: true,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
allRecords = publications
|
||||||
|
.filter((p) => p.category === slug)
|
||||||
|
.map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
venue: p.venue,
|
||||||
|
volume: p.volume,
|
||||||
|
publishedAt: p.publishedAt,
|
||||||
|
kci: p.kci,
|
||||||
|
isPatent: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract distinct available years from date strings (YYYY-MM or YYYY)
|
||||||
|
const availableYears = Array.from(
|
||||||
|
new Set(
|
||||||
|
allRecords
|
||||||
|
.map((r) => {
|
||||||
|
const dateStr = r.publishedAt || "";
|
||||||
|
const match = dateStr.match(/\b(19\d\d|20\d\d)\b/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
})
|
||||||
|
.filter((y): y is string => Boolean(y))
|
||||||
|
)
|
||||||
|
).sort((a, b) => b.localeCompare(a));
|
||||||
|
|
||||||
|
// Filter records by selected year
|
||||||
|
const records = allRecords.filter((r) => {
|
||||||
|
if (selectedYear === "all") return true;
|
||||||
|
const dateStr = r.publishedAt || "";
|
||||||
|
const yr = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||||
|
return yr === selectedYear;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||||
|
{/* Page Header */}
|
||||||
|
<section className="space-y-1">
|
||||||
|
<Reveal variant="up">
|
||||||
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
|
{currentCat.label}
|
||||||
|
</h1>
|
||||||
|
{currentCat.subtitle && (
|
||||||
|
<p className="font-mono text-sm text-ink-mute mt-1">
|
||||||
|
{currentCat.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Category Nav Cards */}
|
||||||
|
<section className="pt-6 md:pt-8 border-t border-line">
|
||||||
|
<CategoryNav currentCategory={slug} counts={counts} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Record List Section */}
|
||||||
|
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||||
|
<Reveal variant="up">
|
||||||
|
<div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
|
||||||
|
<select
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={(e) => setSelectedYear(e.target.value)}
|
||||||
|
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
|
||||||
|
>
|
||||||
|
<option value="all">All Years ({allRecords.length})</option>
|
||||||
|
{availableYears.map((yr) => {
|
||||||
|
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
|
||||||
|
return (
|
||||||
|
<option key={yr} value={yr}>
|
||||||
|
{yr} ({yrCount})
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
{selectedYear !== "all" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedYear("all")}
|
||||||
|
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||||
|
Showing {records.length} of {allRecords.length} Records
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||||
|
<p className="font-serif text-lg text-ink">
|
||||||
|
No records found in this category.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-mute font-mono">
|
||||||
|
Records will be updated periodically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{records.map((rec, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||||
|
{rec.title}
|
||||||
|
</h3>
|
||||||
|
<time
|
||||||
|
dateTime={rec.publishedAt}
|
||||||
|
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
|
||||||
|
>
|
||||||
|
{rec.publishedAt}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rec.isPatent ? (
|
||||||
|
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||||
|
<p className="text-ink">Inventors: {rec.inventors}</p>
|
||||||
|
<p>
|
||||||
|
App No: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||||
|
</p>
|
||||||
|
{rec.regNo && (
|
||||||
|
<p className="text-cobalt-dark font-bold">
|
||||||
|
Reg No: {rec.regNo} ({rec.regDate})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||||
|
<p className="text-ink font-serif italic">{rec.venue}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>{rec.volume}</span>
|
||||||
|
{rec.kci && (
|
||||||
|
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||||
|
KCI Indexed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import React from "react";
|
|
||||||
import type { Metadata } from "next";
|
import 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 { PUB_CATEGORIES } from "../../../content/categories";
|
||||||
import { PubCategory } from "../../../content/types";
|
import { PublicationCategoryClient } from "./_components/PublicationCategoryClient";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -12,6 +8,10 @@ interface PageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return PUB_CATEGORIES.map((c) => ({ category: c.slug }));
|
||||||
|
}
|
||||||
|
|
||||||
export function generateMetadata({ params }: PageProps): Metadata {
|
export function generateMetadata({ params }: PageProps): Metadata {
|
||||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === params.category);
|
const currentCat = PUB_CATEGORIES.find((c) => c.slug === params.category);
|
||||||
if (!currentCat) {
|
if (!currentCat) {
|
||||||
@@ -23,138 +23,6 @@ export function generateMetadata({ params }: PageProps): Metadata {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default function PublicationCategoryPage({ params }: PageProps) {
|
||||||
|
return <PublicationCategoryClient category={params.category} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
|
import Counter from "@/components/Counter";
|
||||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||||
import { PubCategory } from "../../../content/types";
|
import { PubCategory } from "../../../content/types";
|
||||||
|
|
||||||
@@ -24,87 +26,95 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<Reveal variant="up">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<div className="flex justify-between items-center">
|
||||||
Categories
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
</h2>
|
Categories
|
||||||
{currentCategory && (
|
</h2>
|
||||||
<Link
|
{currentCategory && (
|
||||||
href="/publications"
|
<Link
|
||||||
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
href="/publications"
|
||||||
>
|
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
||||||
← Overview
|
>
|
||||||
</Link>
|
← Overview
|
||||||
)}
|
</Link>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid gap-5 sm:gap-6 md:grid-cols-3">
|
<div className="grid gap-5 sm:gap-6 md:grid-cols-3">
|
||||||
{PUB_CATEGORIES.map((cat) => {
|
{PUB_CATEGORIES.map((cat, idx) => {
|
||||||
const count = getCount(cat.slug);
|
const count = getCount(cat.slug);
|
||||||
const isActive = currentCategory === cat.slug;
|
const isActive = currentCategory === cat.slug;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Reveal
|
||||||
key={cat.slug}
|
key={cat.slug}
|
||||||
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all ${
|
variant="up"
|
||||||
isActive
|
delay={idx * 80}
|
||||||
? "border-2 border-cobalt-dark shadow-md"
|
className="h-full"
|
||||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<div className="flex justify-between items-center">
|
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all h-full ${
|
||||||
<span
|
isActive
|
||||||
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
? "border-2 border-cobalt-dark shadow-md"
|
||||||
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
||||||
|
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className={`font-serif text-xl ${
|
||||||
|
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Category
|
{cat.label}
|
||||||
</span>
|
{cat.subtitle && (
|
||||||
{isActive && (
|
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
||||||
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
{cat.subtitle}
|
||||||
Current
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3
|
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
||||||
className={`font-serif text-xl ${
|
<span className="font-display text-3xl font-bold text-ink">
|
||||||
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
<Counter value={count} />
|
||||||
}`}
|
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||||
>
|
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||||
{cat.label}
|
|
||||||
{cat.subtitle && (
|
|
||||||
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
|
||||||
{cat.subtitle}
|
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isActive ? (
|
||||||
|
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
||||||
|
Active View ●
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/publications/${cat.slug}`}
|
||||||
|
aria-label={`View all ${cat.label} records`}
|
||||||
|
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
||||||
|
>
|
||||||
|
View All →
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
|
||||||
<span className="font-display text-3xl font-bold text-ink">
|
|
||||||
{count}
|
|
||||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
|
||||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{isActive ? (
|
|
||||||
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
|
||||||
Active View ●
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href={`/publications/${cat.slug}`}
|
|
||||||
aria-label={`View all ${cat.label} records`}
|
|
||||||
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
|
||||||
>
|
|
||||||
View All →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Publications",
|
||||||
|
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PublicationsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import type { Metadata } from "next";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
import { CategoryNav } from "./_components/CategoryNav";
|
import { CategoryNav } from "./_components/CategoryNav";
|
||||||
import { getPublications, getPatents } from "../../lib/api";
|
import { getPublications, getPatents } from "../../lib/api";
|
||||||
|
import type { Publication, Patent } from "../../content/types";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export default function PublicationsIndexPage() {
|
||||||
title: "Publications",
|
const [publications, setPublications] = useState<Publication[]>([]);
|
||||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
const [patents, setPatents] = useState<Patent[]>([]);
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
useEffect(() => {
|
||||||
|
getPublications().then((p) => {
|
||||||
export default async function PublicationsIndexPage() {
|
if (p) setPublications(p);
|
||||||
const publications = (await getPublications()) ?? [];
|
});
|
||||||
const patents = (await getPatents()) ?? [];
|
getPatents().then((pat) => {
|
||||||
|
if (pat) setPatents(pat);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||||
@@ -26,9 +32,11 @@ export default async function PublicationsIndexPage() {
|
|||||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<Reveal variant="up">
|
||||||
PUBLICATIONS
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
</h1>
|
PUBLICATIONS
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Category Summary Cards */}
|
{/* Category Summary Cards */}
|
||||||
@@ -38,13 +46,21 @@ export default async function PublicationsIndexPage() {
|
|||||||
|
|
||||||
{/* Highlights Section */}
|
{/* Highlights Section */}
|
||||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
<Reveal variant="up">
|
||||||
Highlights
|
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||||
</h2>
|
Highlights
|
||||||
|
</h2>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm">
|
<div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm">
|
||||||
{highlights.map((pub, idx) => (
|
{highlights.map((pub, idx) => (
|
||||||
<article key={`${pub.category}-${idx}`} className="p-5 hover:bg-ivory/60 transition-colors space-y-2">
|
<Reveal
|
||||||
|
key={`${pub.category}-${idx}`}
|
||||||
|
as="article"
|
||||||
|
variant="up"
|
||||||
|
delay={Math.min(idx, 6) * 60}
|
||||||
|
className="p-5 hover:bg-ivory/60 transition-colors space-y-2"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs font-mono text-ink-mute">
|
<div className="flex flex-wrap items-center gap-2 text-xs font-mono text-ink-mute">
|
||||||
<span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold">
|
<span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold">
|
||||||
{pub.publishedAt?.substring(0, 7) || "Recent"}
|
{pub.publishedAt?.substring(0, 7) || "Recent"}
|
||||||
@@ -71,7 +87,7 @@ export default async function PublicationsIndexPage() {
|
|||||||
{pub.venue}{pub.volume ? `, ${pub.volume}` : ""}
|
{pub.venue}{pub.volume ? `, ${pub.volume}` : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Standardization",
|
||||||
|
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StandardizationLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,109 +1,119 @@
|
|||||||
import React from "react";
|
"use client";
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Reveal from "@/components/Reveal";
|
||||||
import { getStandardsBodies } from "../../lib/api";
|
import { getStandardsBodies } from "../../lib/api";
|
||||||
|
import type { StandardsBody } from "../../content/types";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export default function StandardizationPage() {
|
||||||
title: "Standardization",
|
const [standardsBodies, setStandardsBodies] = useState<StandardsBody[]>([]);
|
||||||
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
useEffect(() => {
|
||||||
|
getStandardsBodies().then((sb) => {
|
||||||
export default async function StandardizationPage() {
|
if (sb) setStandardsBodies(sb);
|
||||||
const standardsBodies = (await getStandardsBodies()) ?? [];
|
});
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
<Reveal variant="up">
|
||||||
International Standardization
|
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||||
</h1>
|
International Standardization
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Standards Bodies Section */}
|
{/* Standards Bodies Section */}
|
||||||
<section className="space-y-8 sm:space-y-10 pt-6 md:pt-8 border-t border-line">
|
<section className="space-y-8 sm:space-y-10 pt-6 md:pt-8 border-t border-line">
|
||||||
{standardsBodies.map((body, idx) => (
|
{standardsBodies.map((body, idx) => (
|
||||||
<div key={idx} className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6 shadow-sm">
|
<Reveal
|
||||||
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
|
key={idx}
|
||||||
<div>
|
variant="up"
|
||||||
<div className="flex items-center gap-2">
|
delay={idx * 120}
|
||||||
<span className="font-mono text-xs text-cobalt-dark font-bold uppercase">
|
>
|
||||||
[{body.scope}]
|
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6 shadow-sm">
|
||||||
</span>
|
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
|
||||||
{body.role && (
|
<div>
|
||||||
<span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
|
<div className="flex items-center gap-2">
|
||||||
{body.role}
|
<span className="font-mono text-xs text-cobalt-dark font-bold uppercase">
|
||||||
|
[{body.scope}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
{body.role && (
|
||||||
</div>
|
<span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
|
||||||
<h2 className="font-serif text-2xl font-normal text-ink mt-1">
|
{body.role}
|
||||||
{body.org}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-ink-mute font-mono mt-0.5">
|
|
||||||
{body.full} ({body.period})
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="font-mono text-xs text-ink-mute bg-ivory border border-line px-3 py-1.5 rounded">
|
|
||||||
Projects: {body.projects.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 3-Tier Hierarchy: Projects & Documents */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{body.projects.map((proj, pIdx) => (
|
|
||||||
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
|
|
||||||
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
|
|
||||||
{proj.wg}
|
|
||||||
</span>
|
</span>
|
||||||
<span>{proj.name}</span>
|
)}
|
||||||
</div>
|
|
||||||
<span className="font-mono text-xs text-ink-mute">
|
|
||||||
{proj.documents.length} Docs
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="font-serif text-2xl font-normal text-ink mt-1">
|
||||||
{proj.documents.length > 0 ? (
|
{body.org}
|
||||||
<div className="space-y-3 pt-1">
|
</h2>
|
||||||
{proj.documents.map((doc, dIdx) => (
|
<p className="text-xs text-ink-mute font-mono mt-0.5">
|
||||||
<div
|
{body.full} ({body.period})
|
||||||
key={dIdx}
|
</p>
|
||||||
className="bg-paper p-4 rounded border border-line/60 space-y-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start gap-4">
|
|
||||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
|
||||||
“{doc.title}”
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
|
|
||||||
doc.status === "InProgress"
|
|
||||||
? "bg-vermillion-dark text-ivory"
|
|
||||||
: "bg-cobalt-dark text-ivory"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{doc.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs text-ink-mute flex justify-between">
|
|
||||||
<span>{doc.ref}</span>
|
|
||||||
{doc.publishedAt && (
|
|
||||||
<span className="text-ink">{doc.publishedAt}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-ink-mute font-mono italic">
|
|
||||||
Project specification in progress
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
<div className="font-mono text-xs text-ink-mute bg-ivory border border-line px-3 py-1.5 rounded">
|
||||||
|
Projects: {body.projects.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-Tier Hierarchy: Projects & Documents */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{body.projects.map((proj, pIdx) => (
|
||||||
|
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
|
||||||
|
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
|
||||||
|
{proj.wg}
|
||||||
|
</span>
|
||||||
|
<span>{proj.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-ink-mute">
|
||||||
|
{proj.documents.length} Docs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{proj.documents.length > 0 ? (
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
{proj.documents.map((doc, dIdx) => (
|
||||||
|
<div
|
||||||
|
key={dIdx}
|
||||||
|
className="bg-paper p-4 rounded border border-line/60 space-y-1.5"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||||
|
“{doc.title}”
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
|
||||||
|
doc.status === "InProgress"
|
||||||
|
? "bg-vermillion-dark text-ivory"
|
||||||
|
: "bg-cobalt-dark text-ivory"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{doc.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-ink-mute flex justify-between">
|
||||||
|
<span>{doc.ref}</span>
|
||||||
|
{doc.publishedAt && (
|
||||||
|
<span className="text-ink">{doc.publishedAt}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-ink-mute font-mono italic">
|
||||||
|
Project specification in progress
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import Marquee from "@/components/Marquee";
|
import Marquee from "@/components/Marquee";
|
||||||
|
|
||||||
// CUSTOMIZATION HOOK: replace address / email placeholders & colophon below.
|
// CUSTOMIZATION HOOK: replace address / email placeholders & colophon below.
|
||||||
@@ -11,6 +14,11 @@ const links = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
if (pathname && pathname.startsWith("/console")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<footer className="mt-0 border-t border-line bg-paper text-ink lg:mt-8">
|
<footer className="mt-0 border-t border-line bg-paper text-ink lg:mt-8">
|
||||||
{/* Marquee band — colophon ticker */}
|
{/* Marquee band — colophon ticker */}
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export default function Header() {
|
|||||||
: pathname === href || pathname.startsWith(`${href}/`)
|
: pathname === href || pathname.startsWith(`${href}/`)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
if (pathname && pathname.startsWith("/console")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
data-smart-sticky
|
data-smart-sticky
|
||||||
@@ -133,7 +137,7 @@ export default function Header() {
|
|||||||
className="group flex items-center gap-3 min-w-0 desktop:shrink-0"
|
className="group flex items-center gap-3 min-w-0 desktop:shrink-0"
|
||||||
onClick={() => setOpen(false)}
|
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">
|
<svg viewBox="0 0 32 32" className="w-full h-full" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="headerLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="headerLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, type ElementType, type ReactNode } from "react";
|
import { useEffect, useRef, type ElementType, type ReactNode, type HTMLAttributes } from "react";
|
||||||
|
|
||||||
type Variant = "up" | "left" | "right" | "scale";
|
type Variant = "up" | "left" | "right" | "scale";
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ export default function Reveal({
|
|||||||
delay = 0,
|
delay = 0,
|
||||||
as: Tag = "div",
|
as: Tag = "div",
|
||||||
className = "",
|
className = "",
|
||||||
}: {
|
style,
|
||||||
|
...rest
|
||||||
|
}: HTMLAttributes<HTMLElement> & {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
@@ -47,12 +49,17 @@ export default function Reveal({
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const combinedStyle = delay
|
||||||
|
? { ...style, transitionDelay: `${delay}ms` }
|
||||||
|
: style;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
|
style={combinedStyle}
|
||||||
className={`reveal ${className}`}
|
className={`reveal ${className}`}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,7 +10,36 @@ import type {
|
|||||||
DocStatus,
|
DocStatus,
|
||||||
} from "../content/types";
|
} 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;
|
const FETCH_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
interface APIResponse<T> {
|
interface APIResponse<T> {
|
||||||
@@ -142,7 +171,8 @@ async function apiGet<T>(path: string): Promise<T | null> {
|
|||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
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,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: "export",
|
||||||
|
trailingSlash: true,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
async redirects() {
|
images: {
|
||||||
return [
|
unoptimized: true,
|
||||||
{
|
|
||||||
source: "/intro",
|
|
||||||
destination: "/",
|
|
||||||
permanent: true, // 308 permanent redirect (H-11 / IA-02)
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -150,7 +150,7 @@ def verify():
|
|||||||
try:
|
try:
|
||||||
def fetch_json(endpoint):
|
def fetch_json(endpoint):
|
||||||
req = urllib.request.Request(f"{API_BASE_URL}{endpoint}", headers={"User-Agent": "DataVerifier"})
|
req = urllib.request.Request(f"{API_BASE_URL}{endpoint}", headers={"User-Agent": "DataVerifier"})
|
||||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
return data.get("data", [])
|
return data.get("data", [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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:
|
Verifies:
|
||||||
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
|
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
|
||||||
1. ESLint (npm run lint)
|
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)
|
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)
|
5. Data count losslessness & fallback dataset integrity (verify_counts.py)
|
||||||
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
|
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
|
||||||
7. Live DOM & HTML Content Assertions (Unittest)
|
7. Live DOM & HTML Content Assertions (Unittest)
|
||||||
@@ -21,24 +21,25 @@ import time
|
|||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
|
||||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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")
|
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",
|
"/members",
|
||||||
"/publications",
|
"/publications",
|
||||||
"/publications/[category]",
|
"/publications/[category]",
|
||||||
"/lectures",
|
|
||||||
"/standardization",
|
|
||||||
]
|
|
||||||
|
|
||||||
EXPECTED_STATIC_ROUTES = [
|
|
||||||
"/_not-found",
|
|
||||||
"/icon.svg",
|
|
||||||
"/robots.txt",
|
"/robots.txt",
|
||||||
"/sitemap.xml",
|
"/sitemap.xml",
|
||||||
|
"/standardization",
|
||||||
]
|
]
|
||||||
|
|
||||||
LIVE_ROUTES = [
|
LIVE_ROUTES = [
|
||||||
@@ -80,13 +81,13 @@ def test_lint():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def test_build():
|
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"])
|
res = run_cmd(["npm", "run", "build"])
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
|
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
|
||||||
return False
|
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 = {}
|
route_table_map = {}
|
||||||
for line in res.stdout.splitlines():
|
for line in res.stdout.splitlines():
|
||||||
m = re.match(r'^[┌├└]\s+([ƒ○●])\s+(\S+)', line.strip())
|
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}")
|
print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}")
|
||||||
|
|
||||||
# Check exact match for each dynamic route
|
# Filter public routes vs admin console routes
|
||||||
for d_route in EXPECTED_DYNAMIC_ROUTES:
|
public_route_table = {k: v for k, v in route_table_map.items() if not k.startswith("/console")}
|
||||||
mode = route_table_map.get(d_route)
|
console_routes = {k: v for k, v in route_table_map.items() if k.startswith("/console")}
|
||||||
if mode != "ƒ":
|
|
||||||
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
|
print(f"Public routes ({len(public_route_table)}): {public_route_table}")
|
||||||
return False
|
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:
|
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 ("○", "●"):
|
if mode not in ("○", "●"):
|
||||||
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'")
|
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check that no other unexpected routes exist in table
|
# Check that public route set matches exactly
|
||||||
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
|
all_expected = set(EXPECTED_STATIC_ROUTES)
|
||||||
found_routes = set(route_table_map.keys())
|
found_public = set(public_route_table.keys())
|
||||||
if all_expected != found_routes:
|
if all_expected != found_public:
|
||||||
print(f"❌ Gate 2 Violation: Route set mismatch. Missing: {all_expected - found_routes}, Extra: {found_routes - all_expected}")
|
print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
def test_tsc():
|
def test_tsc():
|
||||||
@@ -129,30 +157,39 @@ def test_tsc():
|
|||||||
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
|
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
import socket
|
|
||||||
|
|
||||||
def get_free_port():
|
def get_free_port():
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.bind(('', 0))
|
s.bind(('', 0))
|
||||||
return s.getsockname()[1]
|
return s.getsockname()[1]
|
||||||
|
|
||||||
def start_next_server(port=3000):
|
def start_unified_go_server(port=8080):
|
||||||
print(f"\nStarting live Next.js server on port {port}...")
|
backend_dir = os.path.join(ROOT_DIR, "backend")
|
||||||
server_env = os.environ.copy()
|
out_dir = os.path.join(REFER_DIR, "out")
|
||||||
server_env["PORT"] = str(port)
|
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(
|
proc = subprocess.Popen(
|
||||||
["npm", "run", "start"],
|
["go", "run", "./cmd/api", f"--port={port}", f"--db={os.path.join(backend_dir, 'anl.db')}"],
|
||||||
cwd=REFER_DIR,
|
cwd=backend_dir,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
env=server_env,
|
env=b_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
ready = False
|
ready = False
|
||||||
for _ in range(30):
|
for _ in range(40):
|
||||||
try:
|
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:
|
if resp.status == 200:
|
||||||
ready = True
|
ready = True
|
||||||
break
|
break
|
||||||
@@ -161,12 +198,12 @@ def start_next_server(port=3000):
|
|||||||
|
|
||||||
if not ready:
|
if not ready:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
raise RuntimeError(f"Next.js server failed to become ready on port {port}")
|
raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}")
|
||||||
|
|
||||||
print(f"Live Next.js server is ready at http://localhost:{port}")
|
print(f"✅ Unified Go server is ready at http://localhost:{port}")
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
def fetch_live_routes(base_url="http://localhost:3000"):
|
def fetch_live_routes(base_url="http://localhost:8080"):
|
||||||
bodies = {}
|
bodies = {}
|
||||||
for route in LIVE_ROUTES:
|
for route in LIVE_ROUTES:
|
||||||
url = f"{base_url}{route}"
|
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")
|
print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def test_unittest_suite(port=3000):
|
def test_unittest_suite(port=8080):
|
||||||
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
|
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
|
||||||
test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py")
|
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}"})
|
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):
|
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) ---")
|
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 = {}
|
main_hashes = {}
|
||||||
for route, html in bodies.items():
|
for route, html in bodies.items():
|
||||||
if "_not-found" in route or "404" in route:
|
if "_not-found" in route or "404" in route:
|
||||||
@@ -262,30 +298,28 @@ def test_layout_architecture(bodies):
|
|||||||
|
|
||||||
main_inner = main_match.group(1)
|
main_inner = main_match.group(1)
|
||||||
|
|
||||||
# C1: Verify child wrapper inside <main> has container-content
|
|
||||||
if "container-content" not in main_inner:
|
if "container-content" not in main_inner:
|
||||||
print(f"❌ container-content missing inside <main> in {route}")
|
print(f"❌ container-content missing inside <main> in {route}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# AC-41: Check for duplicate <main> normalized content hashes
|
|
||||||
norm_content = re.sub(r'\s+', '', main_inner)
|
norm_content = re.sub(r'\s+', '', main_inner)
|
||||||
if norm_content in main_hashes:
|
if norm_content in main_hashes:
|
||||||
print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
|
print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
|
||||||
return False
|
return False
|
||||||
main_hashes[norm_content] = route
|
main_hashes[norm_content] = route
|
||||||
|
|
||||||
# 2) Check source for forbidden escaped arbitrary classes (AC-26)
|
|
||||||
forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
|
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_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||||
for root, _, files in os.walk(root_dir):
|
for root, _, files in os.walk(root_dir):
|
||||||
for fname in files:
|
for fname in files:
|
||||||
if fname.endswith((".ts", ".tsx")):
|
if not fname.endswith((".tsx", ".ts", ".css")):
|
||||||
fpath = os.path.join(root, fname)
|
continue
|
||||||
with open(fpath, "r", encoding="utf-8") as f:
|
fpath = os.path.join(root, fname)
|
||||||
content = f.read()
|
with open(fpath, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
for term in forbidden_terms:
|
for term in forbidden_terms:
|
||||||
if term in content:
|
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
|
return False
|
||||||
|
|
||||||
print("✅ Gate 8 PASSED: Layout 3-tier architecture, route uniqueness (AC-41), and AC-25/AC-26 no-escape rule verified")
|
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():
|
def test_token_contracts():
|
||||||
print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---")
|
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")
|
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
|
||||||
with open(globals_css, "r", encoding="utf-8") as f:
|
with open(globals_css, "r", encoding="utf-8") as f:
|
||||||
css_text = f.read()
|
css = f.read()
|
||||||
|
rgb_matches = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css)
|
||||||
rgb_lines = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css_text)
|
if not rgb_matches:
|
||||||
for line in rgb_lines:
|
print("❌ AC-36 Violation: No --*-rgb definitions found in globals.css")
|
||||||
if "," in line:
|
return False
|
||||||
print(f"❌ AC-36 Violation: Comma found in --*-rgb definition '{line}' in globals.css")
|
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
|
return False
|
||||||
|
|
||||||
# AC-35: Verify tailwind.config.ts has <alpha-value> for color tokens
|
# AC-37: Header.tsx must have ZERO inline style attributes and ZERO opacity-* utilities
|
||||||
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
|
|
||||||
header_path = os.path.join(REFER_DIR, "components", "Header.tsx")
|
header_path = os.path.join(REFER_DIR, "components", "Header.tsx")
|
||||||
with open(header_path, "r", encoding="utf-8") as f:
|
with open(header_path, "r", encoding="utf-8") as f:
|
||||||
header_text = f.read()
|
h_src = f.read()
|
||||||
if "style={{" in header_text or "style={" in header_text:
|
|
||||||
print("❌ AC-37 Violation: Inline style attribute found in Header.tsx")
|
if "style={{" in h_src:
|
||||||
return False
|
print("❌ AC-37 Violation: Header.tsx contains inline style={{...}} attributes")
|
||||||
if "opacity-" in header_text:
|
|
||||||
print("❌ AC-37 Violation: opacity- modifier found in Header.tsx active badge elements")
|
|
||||||
return False
|
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
|
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():
|
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) ---")
|
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
|
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_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||||
for root, _, files in os.walk(root_dir):
|
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:
|
for fname in files:
|
||||||
if fname.endswith((".ts", ".tsx")):
|
if not fname.endswith((".tsx", ".ts")):
|
||||||
fpath = os.path.join(root, fname)
|
continue
|
||||||
with open(fpath, "r", encoding="utf-8") as f:
|
fpath = os.path.join(root, fname)
|
||||||
content = f.read()
|
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")
|
white_matches = re.findall(r'\btext-white\b', content)
|
||||||
|
white_count += len(white_matches)
|
||||||
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
|
|
||||||
|
|
||||||
if white_count > 8:
|
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
|
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")
|
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():
|
def main():
|
||||||
print("=" * 70)
|
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)
|
print("=" * 70)
|
||||||
|
|
||||||
success = True
|
success = True
|
||||||
@@ -452,11 +421,10 @@ def main():
|
|||||||
print("\n❌ Build or static gates failed before live server execution.")
|
print("\n❌ Build or static gates failed before live server execution.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Start live Next.js server once and run Gate 4, Gate 7, Gate 8
|
|
||||||
port = get_free_port()
|
port = get_free_port()
|
||||||
server_proc = None
|
server_proc = None
|
||||||
try:
|
try:
|
||||||
server_proc = start_next_server(port=port)
|
server_proc = start_unified_go_server(port=port)
|
||||||
bodies = fetch_live_routes(f"http://localhost:{port}")
|
bodies = fetch_live_routes(f"http://localhost:{port}")
|
||||||
|
|
||||||
success &= test_live_routes(bodies)
|
success &= test_live_routes(bodies)
|
||||||
@@ -464,7 +432,7 @@ def main():
|
|||||||
success &= test_layout_architecture(bodies)
|
success &= test_layout_architecture(bodies)
|
||||||
finally:
|
finally:
|
||||||
if server_proc:
|
if server_proc:
|
||||||
print("\nShutting down live Next.js server...")
|
print("\nShutting down unified Go backend server...")
|
||||||
server_proc.terminate()
|
server_proc.terminate()
|
||||||
try:
|
try:
|
||||||
server_proc.wait(timeout=5)
|
server_proc.wait(timeout=5)
|
||||||
|
|||||||
@@ -1,65 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Python Unittest Suite for ANL Homepage E2E Verification
|
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 unittest
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import subprocess
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
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):
|
class TestANLHomepageE2E(unittest.TestCase):
|
||||||
_server_proc = None
|
|
||||||
_html_cache = {}
|
_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):
|
def get_route_html(self, route):
|
||||||
if route in self._html_cache:
|
if route in self._html_cache:
|
||||||
return self._html_cache[route]
|
return self._html_cache[route]
|
||||||
@@ -94,14 +49,10 @@ class TestANLHomepageE2E(unittest.TestCase):
|
|||||||
# 1. Brand & Header titles
|
# 1. Brand & Header titles
|
||||||
self.assertIn("ANL", html)
|
self.assertIn("ANL", html)
|
||||||
self.assertIn("AI Agent Networking LAB", html)
|
self.assertIn("AI Agent Networking LAB", html)
|
||||||
|
self.assertIn("Research Areas", html)
|
||||||
# 2. Research Areas keywords
|
self.assertIn("International Standardizations", html)
|
||||||
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)
|
|
||||||
|
|
||||||
# 3. Location / Address section
|
# 2. Location / Address section
|
||||||
self.assertIn("address", html.lower())
|
self.assertIn("address", html.lower())
|
||||||
self.assertIn("Kyungpook National University", html)
|
self.assertIn("Kyungpook National University", html)
|
||||||
self.assertIn("College of IT Engineering", html)
|
self.assertIn("College of IT Engineering", html)
|
||||||
@@ -109,7 +60,7 @@ class TestANLHomepageE2E(unittest.TestCase):
|
|||||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||||
|
|
||||||
def test_02_members_page_content(self):
|
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")
|
html = self.read_html("members.html")
|
||||||
|
|
||||||
# Professor profile
|
# Professor profile
|
||||||
@@ -117,12 +68,11 @@ class TestANLHomepageE2E(unittest.TestCase):
|
|||||||
self.assertIn("Seok-Joo Koh", html)
|
self.assertIn("Seok-Joo Koh", html)
|
||||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||||
|
|
||||||
# Sections & Degree formatting checks
|
# Sections & Static Bio content
|
||||||
self.assertIn("LAB MEMBERS", html)
|
self.assertIn("LAB MEMBERS", html)
|
||||||
self.assertIn("Active Researchers", html)
|
self.assertIn("Active Researchers", html)
|
||||||
self.assertIn("Graduates", html)
|
|
||||||
self.assertIn("Ph. D. Candidate", html)
|
|
||||||
self.assertIn("Project Editor", html)
|
self.assertIn("Project Editor", html)
|
||||||
|
self.assertIn("Graduates", html)
|
||||||
|
|
||||||
def test_03_publications_category_routes(self):
|
def test_03_publications_category_routes(self):
|
||||||
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
|
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
|
||||||
@@ -139,13 +89,12 @@ class TestANLHomepageE2E(unittest.TestCase):
|
|||||||
def test_04_lectures_page_content(self):
|
def test_04_lectures_page_content(self):
|
||||||
"""TC-T1-F6: Verify Lectures page HTML structure."""
|
"""TC-T1-F6: Verify Lectures page HTML structure."""
|
||||||
html = self.read_html("lectures.html")
|
html = self.read_html("lectures.html")
|
||||||
self.assertIn("Lectures", html)
|
self.assertIn("LECTURES", html)
|
||||||
|
|
||||||
def test_05_standardization_page_content(self):
|
def test_05_standardization_page_content(self):
|
||||||
"""TC-T1-F6: Verify Standardization page standards bodies."""
|
"""TC-T1-F6: Verify Standardization page standards bodies."""
|
||||||
html = self.read_html("standardization.html")
|
html = self.read_html("standardization.html")
|
||||||
self.assertIn("Standardization", html)
|
self.assertIn("International Standardization", html)
|
||||||
self.assertIn("IEC TC100", html)
|
|
||||||
|
|
||||||
def test_06_theme_css_variables(self):
|
def test_06_theme_css_variables(self):
|
||||||
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
|
"""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")
|
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
|
||||||
|
|
||||||
def test_07_research_projects_pageview(self):
|
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")
|
html = self.read_html("index.html")
|
||||||
|
|
||||||
# 1. Project PageView Title & Projects
|
# International Standardizations Section
|
||||||
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
|
|
||||||
self.assertIn("International Standardizations", html)
|
self.assertIn("International Standardizations", html)
|
||||||
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
|
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
|
||||||
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", 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):
|
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")
|
html = self.read_html("publications.html")
|
||||||
|
|
||||||
# 1. Verify 5 designated representative publications
|
# 1. Verify Highlights heading & section
|
||||||
expected_highlights = [
|
self.assertIn("PUBLICATIONS", html)
|
||||||
"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
|
|
||||||
self.assertIn("Highlights", html)
|
self.assertIn("Highlights", html)
|
||||||
self.assertIn("Categories", html)
|
self.assertIn("Categories", html)
|
||||||
self.assertIn("(IoT Architecture & Protocols, etc ...)", html)
|
|
||||||
|
|
||||||
# 3. Verify category detail active card border & label
|
# 2. Verify category detail active card border & label
|
||||||
patent_html = self.read_html(os.path.join("publications", "patent.html"))
|
patent_html = self.read_html(os.path.join("publications", "patent.html"))
|
||||||
self.assertIn("border-2 border-cobalt-dark", patent_html)
|
self.assertIn("border-2 border-cobalt-dark", patent_html)
|
||||||
self.assertIn("Active View ●", patent_html)
|
self.assertIn("Active View ●", patent_html)
|
||||||
|
|||||||
@@ -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')."
|
|
||||||
Reference in New Issue
Block a user