Compare commits
19
Commits
main
...
78a66b7ad4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
*.zip
|
||||
|
||||
# Local SQLite databases and bind-mount volumes
|
||||
/volumes/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
!backend/db/backup/*.sql
|
||||
# >>> MAM managed block (managed by install.sh — do not edit) >>>
|
||||
/.venv/
|
||||
/.mam/
|
||||
|
||||
+2
-2
@@ -129,8 +129,8 @@
|
||||
# SKS_EMPTY_GIVEUP=3
|
||||
|
||||
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
|
||||
#default: 60
|
||||
# MAM_MIN_PANE_COLS=60
|
||||
#default: 40
|
||||
# MAM_MIN_PANE_COLS=40
|
||||
|
||||
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
|
||||
#default: 20
|
||||
|
||||
@@ -1,123 +1,231 @@
|
||||
# 🌐 경북대학교 AI 에이전트 네트워크 연구실 (ANL)
|
||||
# 🌐 경북대학교 AI 에이전트 네트워크 연구실 (ANL) 공식 웹사이트
|
||||
|
||||
> 본 프로젝트는 **경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실 (AI Agent Networking Laboratory, ANL)**의 공식 홈페이지를 차세대 웹 기술 스택(Next.js 14+, TypeScript, Tailwind CSS)으로 리뉴얼 및 구축하기 위한 프로젝트입니다.
|
||||
> **AI Agent Networking Laboratory (ANL), School of Computer Science and Engineering, Kyungpook National University**
|
||||
>
|
||||
> 연구실의 기존 실측 데이터 원본(`ISL-2026/`), 최신 멀티에이전트 오케스트레이션 연구/세미나 자료(`multi-agent-orchestration/`), 그리고 참고용 모던 웹 프로토타입(`refer_landing_page/`)을 통합하여 연구실의 새로운 기술 비전을 담은 랜딩페이지를 개발합니다.
|
||||
> 본 리포지토리는 경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실의 공식 웹사이트 및 데이터 관리 대시보드 시스템입니다.
|
||||
> **Next.js 14+ 정적 익스포트(Static Export) & 클라이언트 렌더링(CSR)**, **Go (Gin) 고성능 단일 통합 웹/REST API 서버**, **SQLite 트랜잭션 데이터베이스**, 그리고 **단일 컨테이너 Docker 배포 환경**으로 구축되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 📌 1. 연구실 개요 및 핵심 연구 분야 (Lab Overview & Core Research Focus)
|
||||
## 📌 1. 연구실 개요 및 핵심 연구 분야 (Lab Overview & Research Focus)
|
||||
|
||||
- **연구실명**: AI 에이전트 네트워크 연구실 (ANL / AI Agent Networking Laboratory)
|
||||
- **소속**: 경북대학교 IT대학 컴퓨터학부
|
||||
- **지도교수**: 고석주 교수 (Prof. Seok-Joo Koh / `sjkoh@knu.ac.kr`)
|
||||
- **연구 패러다임 전환 (Research Paradigm Shift)**:
|
||||
- *Legacy (과거)*: 단일 디바이스 연동 및 전통적 사물인터넷(oneM2M, OCF) 표준 연구
|
||||
- **Present (현재 핵심 연구)**: **MCP & SKILL**, **멀티에이전트 오케스트레이션 인프라 (HERDR, LangGraph)**, **이종 에이전트 상호운용성 표준 (ACP, A2A)**, 및 **AIoT gRPC/QUIC 고성능 통신 백본**
|
||||
- **연구 패러다임**:
|
||||
- **Present & Future**: **MCP & SKILL**, **멀티에이전트 오케스트레이션 (HERDR, crewAI, LangGraph)**, **이종 에이전트 상호운용성 표준 (ACP, A2A)**, 및 **AIoT gRPC/QUIC 고성능 통신 백본**
|
||||
|
||||
---
|
||||
|
||||
## 💡 2. 핵심 연구 분야 (Core Research Areas)
|
||||
|
||||
`multi-agent-orchestration/` 연구 자산에 기반한 ANL 연구실의 주요 연구 분야입니다:
|
||||
|
||||
### 🔌 1) AI Agent 기능 확장 프로토콜 (MCP & SKILL)
|
||||
- **MCP (Model Context Protocol)**: Anthropic 제정 오픈 규격으로 로컬/원격 도구, 데이터(Resource), 프롬프트 통일 호출 인터페이스
|
||||
- **SKILL (자율 확장 도구 모음)**: 실행 코드 + 도구 명세 스키마 + Few-shot이 결합된 모듈화 패키징 기술
|
||||
- **CLI 코딩 에이전트**: Claude Code, Antigravity CLI 등 터미널 기반 파일 제어 및 자율 소프트웨어 엔지니어링
|
||||
- **MCP (Model Context Protocol)**: Anthropic 제정 오픈 규격으로 도구, 데이터 리소스, 프롬프트 표준 인터페이스 연동
|
||||
- **SKILL (자율 확장 도구 패키지)**: 실행 코드 + 도구 명세 + Few-shot이 결합된 모듈형 에이전트 기능 확장
|
||||
- **CLI 자율 코딩 에이전트**: Claude Code, Antigravity CLI 등 터미널 기반 자율 소프트웨어 엔지니어링
|
||||
|
||||
### 🤖 2) 멀티 에이전트 협업 및 오케스트레이션 인프라 (Multi-Agent Orchestration)
|
||||
- **단일 에이전트 한계 극복**: Lost in the middle 현상 방지, 환각(Hallucination) 억제 및 역할 세분화(Role Specialization)
|
||||
- **이종 에이전트 교차 검증**: 서로 다른 LLM 모델간 비평/합의 루프를 통한 신뢰성 극대화
|
||||
### 🤖 2) 멀티 에이전트 협업 및 오케스트레이션 (Multi-Agent Orchestration)
|
||||
- **이종 에이전트 교차 검증**: 서로 다른 LLM 모델(Planner, Creator, Reviewer) 간 비평/합의 루프로 무결성 확보
|
||||
- **오케스트레이션 인프라 코어**:
|
||||
- **HERDR**: 가상 백그라운드 프로세스 컨테이너 기반 에이전트 세션 격리 및 TUI 라이프사이클 관제
|
||||
- **crewAI**: 역할(Role), 목표(Goal), 배경(Backstory) 기반 순차/계층 크루 프로세스
|
||||
- **LangGraph**: 상태 보존형 순환 그래프(Cyclic Graph), 루프 제어, 예외 롤백 및 상태 동기화
|
||||
- **HERDR**: 가상 백그라운드 프로세스 격리 및 에이전트 세션 TUI 관제
|
||||
- **LangGraph & crewAI**: 상태 보존형 순환 그래프, 역할 기반 분업 및 예외 롤백 처리
|
||||
|
||||
### 🌐 3) 에이전트 상호운용성 개방형 표준 (Agent Interoperability: ACP & A2A)
|
||||
- **ACP (Agent Communication Protocol)**: IBM Research 제정 서비스 발견 및 태스크 위임 오픈 표준
|
||||
- **A2A (Agent-to-Agent Protocol)**: Google 및 Linux Foundation 주도의 이종 에이전트 간 표준 메시지 교환 및 상태 추적 통신 표준
|
||||
- **Agent Card (`/.well-known/agent-card.json`)**: 에이전트의 역량(Capabilities), 엔드포인트, 보안 사양을 명시하는 표준 JSON 프로필
|
||||
- **A2A (Agent-to-Agent Protocol)**: Google/Linux Foundation 주도 이종 에이전트 간 표준 메시지 교환 통신 표준
|
||||
- **Agent Card (`/.well-known/agent-card.json`)**: 에이전트 역량(Capabilities) 및 보안 사양 표준 프로필
|
||||
|
||||
### ⚡ 4) 지능형 사물인터넷 (AIoT) & 고성능 통신 백본 (AIoT & gRPC/QUIC)
|
||||
- **AIoT 분산 자율 제어**: 스마트 팩토리/스마트 팜 등 Physical 센서 계층과 엣지 연산 계층 간 멀티 에이전트 분산 배치
|
||||
- **gRPC & QUIC 양방향 스트리밍**: HTTP/2 & HTTP/3 기반 Protobuf 직렬화 및 대용량 멀티모달 바이너리 고속 전송
|
||||
- **하이브리드 백본 메쉬**: 센서 계층의 경량 프로토콜(MQTT/CoAP)과 엣지 백본의 고성능 프로토콜(gRPC/QUIC) 완충 혼용
|
||||
### ⚡ 4) 지능형 사물인터넷 (AIoT) & 고성능 백본 (AIoT & gRPC/QUIC)
|
||||
- **AIoT 분산 자율 제어**: 센서 계층과 엣지 연산 계층 간 멀티 에이전트 분산 배치
|
||||
- **gRPC & QUIC 양방향 스트리밍**: HTTP/2 & HTTP/3 기반 고속 바이너리 멀티모달 전송
|
||||
|
||||
---
|
||||
|
||||
## 📁 3. 디렉터리 구조 및 역할 (Directory Structure)
|
||||
## 🏗️ 3. 시스템 아키텍처 (System Architecture)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User["🌐 User / Browser"]
|
||||
Admin["👑 Admin User"]
|
||||
|
||||
subgraph Docker_App ["Unified Single Container (anl-app:8080)"]
|
||||
subgraph Go_Gin_Server ["Go Gin Unified Server Engine"]
|
||||
Router["Static File Server & SPA Fallback (/web)"]
|
||||
APIRouter["REST API Router (/api/v1) & CORS"]
|
||||
AuthMW["RequireAdminToken Middleware"]
|
||||
Handlers["CRUD Handlers (Home, Members, Pubs, Lectures, Standards)"]
|
||||
Repo["Repository Layer & Transaction Order Normalizer"]
|
||||
end
|
||||
|
||||
subgraph Static_Web ["Static Exported Frontend (/app/web)"]
|
||||
PublicPages["Public Pages (CSR + SSG Shell)<br/>/, /members, /publications, /lectures, /standardization"]
|
||||
AdminConsole["Admin Dashboard (/console)<br/>Research Projects, Areas, Members, Pubs, Lectures, Standards"]
|
||||
API_Client["Origin-Relative API Client (/api/v1)"]
|
||||
end
|
||||
|
||||
subgraph Storage ["Persistent Storage (anl-sqlite-storage / ./volumes/data)"]
|
||||
SQLiteDB[("SQLite Database<br/>/app/data/anl.db")]
|
||||
end
|
||||
end
|
||||
|
||||
User -->|HTTP :8080| Router
|
||||
Admin -->|Bearer Auth :8080| Router
|
||||
Router --> PublicPages
|
||||
Router --> AdminConsole
|
||||
PublicPages --> API_Client
|
||||
AdminConsole --> API_Client
|
||||
API_Client -->|Internal Route /api/v1| APIRouter
|
||||
APIRouter --> AuthMW
|
||||
AuthMW --> Handlers
|
||||
Handlers --> Repo
|
||||
Repo --> SQLiteDB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 4. 디렉터리 구조 (Directory Structure)
|
||||
|
||||
```
|
||||
landing_page/
|
||||
├── README.md # [본 문서] 5대 연구 기둥 반영 통합 온보딩 가이드
|
||||
├── ISL-2026/ # 📄 [실제 데이터 원본] 기존 연구실 공식 홈페이지 (HTML/CSS 및 교수/연구원/실적 데이터)
|
||||
│ ├── index.html # 메인 랜딩페이지 & 레거시 연구 분야 소스
|
||||
│ ├── member.html # 교수진, 석/박사 연구원, 학부연구생, 졸업생 목록
|
||||
│ ├── paper-international.html # 국외 저널 및 학회 논문 실적
|
||||
│ ├── paper-domestic.html # 국내 저널 및 학회 논문 실적
|
||||
│ ├── patent.html # 특허 등록/출원 실적
|
||||
│ └── standard.html # 국제/국내 표준화 기여 내역 (oneM2M, ITU-T 등)
|
||||
├── README.md # [본 문서] 종합 프로젝트 가이드 및 배포/운영 문서
|
||||
├── docker-compose.yml # 통합 단일 컨테이너 (anl-app) Compose 오케스트레이션
|
||||
├── .env.example # 환경변수 설정 템플릿
|
||||
│
|
||||
├── multi-agent-orchestration/ # 📚 [신규 연구 분야 상세 교육 & 세미나 자료]
|
||||
│ ├── lecture_summary.md # AI Multi-Agents 오케스트레이션 핵심 기술 요약
|
||||
│ ├── SLIDE.md # 연구 세미나 통합 발표 자료
|
||||
│ ├── implementation_plan.md # 오케스트레이션 검토 및 반영 루프 계획
|
||||
│ └── chapters/ # 0~6장 챕터별 상세 연구 문서 (Prompt, MCP, HERDR, A2A, gRPC 등)
|
||||
├── backend/ # ⚡ [통합 Go 백엔드 & 정적 웹 서빙 서버]
|
||||
│ ├── cmd/ # 진입점 (api: 통합 서버, seed: 시더, export-content)
|
||||
│ ├── internal/ # 비즈니스 로직 (handlers, repository, router, config, httpx)
|
||||
│ ├── db/backup/ # 💾 데이터베이스 백업 (anl.db 바이너리, anl_dump.sql 전체 덤프)
|
||||
│ ├── seed/data/ # 초기 원시 JSON 시드 데이터
|
||||
│ ├── Dockerfile # 3단계 통합 Multi-stage Dockerfile (Node 빌드 + Go 빌드 + Alpine 런타임)
|
||||
│ ├── Makefile # 빌드, 테스트, 포맷, 백업(make backup-db) 타겟
|
||||
│ └── go.mod # Go 모듈 의존성 정의
|
||||
│
|
||||
├── refer_landing_page/ # 🚀 [타겟 웹 애플리케이션] Next.js 14+ App Router
|
||||
│ ├── app/ # Next.js App Router 페이지 (Home /, members, publications, lectures, standardization)
|
||||
│ ├── components/ # 모듈화된 UI 컴포넌트 (Header, Footer, PageHeader, SectionLabel, Reveal, Counter, Marquee)
|
||||
│ ├── docs/DESIGN.md # 고대비 매거진 에디토리얼 디자인 시스템 가이드 문서
|
||||
│ └── tailwind.config.ts # 디자인 토큰 및 시각 스타일 규칙 정의
|
||||
├── refer_landing_page/ # 🚀 [Next.js 14 프론트엔드 & 관리자 콘솔 소스]
|
||||
│ ├── app/ # App Router 페이지 (공개 페이지 및 /console 관리자 대시보드)
|
||||
│ ├── components/ # UI 컴포넌트 (Header, Footer, Reveal, Counter, Marquee 등)
|
||||
│ ├── lib/ # API 통신 클라이언트 (api.ts: 조회, adminApi.ts: CRUD)
|
||||
│ ├── docs/ # 디자인 시스템 (DESIGN.md: Issue 01 에디토리얼 스타일)
|
||||
│ ├── scripts/ # E2E 테스트 스위트 및 데이터 검증 러너
|
||||
│ ├── next.config.js # output: "export" 정적 익스포트 설정
|
||||
│ └── package.json # Next.js, React, Tailwind CSS 의존성
|
||||
│
|
||||
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 규칙 및 스킬 툴킷 (MAM)
|
||||
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
|
||||
└── docs/ & research/ # 작업 노트, 결정 기록(ADR) 및 연구 논문 아카이브
|
||||
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 스킬 & 룰셋 (MAM)
|
||||
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
|
||||
└── ISL-2026/ # 📄 레거시 실측 원본 HTML/CSS 아카이브
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 4. 온보딩 개발 미션 (Development Missions for Agents)
|
||||
## 🚀 5. Docker 컨테이너 배포 및 운영 가이드 (Production Deployment)
|
||||
|
||||
### 📥 Mission 1: 실제 레거시 데이터 마이그레이션 & 신규 5대 연구 기둥 반영
|
||||
- `ISL-2026/` 레거시 데이터(교수진, 연구원, 논문, 특허, 강의)를 추출하여 `refer_landing_page/app/<route>/page.tsx` 내 데이터 구조로 교체합니다.
|
||||
- 연구실 메인 랜딩페이지(`app/page.tsx`) 상단 비주얼, 키워드 마퀴(`Marquee`), 카운터 수치(`Counter`)에 **A2A, MCP, HERDR, LangGraph, gRPC/QUIC** 등 5대 신규 연구 키워드 및 성과를 전면 강조합니다.
|
||||
단일 컨테이너(Single Unified Container)를 통해 프론트엔드 정적 웹 에셋과 Go 백엔드 API를 동시에 빌드하고 배포합니다.
|
||||
|
||||
### 🎨 Mission 2: 고대비 에디토리얼 디자인 시스템 준수
|
||||
- `refer_landing_page/docs/DESIGN.md`에 명시된 에디토리얼 디자인 시스템("Issue 01") 규격을 엄격히 준수합니다.
|
||||
- `ISL-2026/assets/`의 로고 및 인물/연구 사진을 `refer_landing_page/public/`으로 이관합니다.
|
||||
### 1) 저장소 클론 및 환경 설정
|
||||
```bash
|
||||
# 1. 저장소 클론
|
||||
git clone <리포지토리_URL> landing_page
|
||||
cd landing_page
|
||||
|
||||
### 🔍 Mission 3: SEO, 반응형 UI & 빌드 검증
|
||||
- 모든 페이지에 SEO 태그(Title, Description, Open Graph, Semantic HTML5) 적용.
|
||||
- `npm run build` 타입/린트 오류 0건 및 반응형 UI 검수.
|
||||
# 2. 환경변수 파일 생성
|
||||
cp .env.example .env
|
||||
|
||||
---
|
||||
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트(APP_PORT) 설정
|
||||
```
|
||||
|
||||
## 👥 5. 멀티 에이전트 역할 (Multi-Agent Team Protocol)
|
||||
### 2) 컨테이너 이미지 빌드
|
||||
```bash
|
||||
# 통합 단일 컨테이너 (anl-app) 빌드
|
||||
docker compose build
|
||||
|
||||
| 에이전트 | 담당 역할 (Role) | 주요 임무 (Primary Responsibilities) |
|
||||
| :--- | :--- | :--- |
|
||||
| 🧠 **Claude** | `planner and reviewer` | 5대 연구 기둥 반영 검토, 데이터 무결성 검증, 최종 코드 PR 승인 |
|
||||
| 🎨 **Agy** (Antigravity) | `creator` | Component 개발, `ISL-2026` 및 `multi-agent-orchestration` 연구 데이터 추출 및 코드 마이그레이션 |
|
||||
| 🔍 **Cline** | `reviewer` | UI/UX 반응형 검수, SEO 및 Accessibility 점검, 린트/타입 안전성 검증 |
|
||||
# (선택) 캐시 없이 전체 재빌드 시
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
> ⚠️ 모든 에이전트는 프로젝트 루트의 [`AGENTS.md`](./AGENTS.md) 지침 및 [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md) 규약을 준수해야 합니다.
|
||||
### 3) 서비스 백그라운드 배포 및 실행
|
||||
```bash
|
||||
# 1. 컨테이너 백그라운드 실행
|
||||
docker compose up -d
|
||||
|
||||
# 2. 실행 상태 및 헬스체크 확인 (anl-app)
|
||||
docker compose ps
|
||||
|
||||
# 3. 실시간 로그 모니터링
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 4) 서비스 접속 URL
|
||||
- **🌐 공식 홈페이지**: [`http://localhost:8080`](http://localhost:8080)
|
||||
- **👑 관리자 대시보드**: [`http://localhost:8080/console`](http://localhost:8080/console) (로그인 토큰: `.env`에 설정된 `ADMIN_TOKEN`)
|
||||
- **⚡ 백엔드 REST API**: [`http://localhost:8080/api/v1`](http://localhost:8080/api/v1)
|
||||
- **💓 API 헬스체크**: [`http://localhost:8080/api/v1/health`](http://localhost:8080/api/v1/health)
|
||||
|
||||
### 5) 📂 호스트 디렉터리 바인드 마운트 (`./volumes/data`)로 변경하는 방법
|
||||
기본 명명된 볼륨 대신 호스트 로컬 폴더에 SQLite DB를 직접 마운트하여 관리하고 싶다면:
|
||||
|
||||
1. `docker-compose.yml`의 `anl-app` 볼륨 설정을 수정합니다:
|
||||
```yaml
|
||||
services:
|
||||
anl-app:
|
||||
# ...
|
||||
volumes:
|
||||
- ./volumes/data:/app/data
|
||||
```
|
||||
2. 파일 맨 아래의 `volumes:` 섹션을 주석 처리하거나 제거합니다.
|
||||
3. 호스트 디렉터리 생성 및 DB 배치:
|
||||
```bash
|
||||
mkdir -p volumes/data
|
||||
cp backend/db/backup/anl.db volumes/data/anl.db
|
||||
```
|
||||
4. 컨테이너 재시작: `docker compose down && docker compose up -d`
|
||||
|
||||
### 6) 컨테이너 관리 및 중지
|
||||
```bash
|
||||
# 서비스 재시작
|
||||
docker compose restart
|
||||
|
||||
# 서비스 중지 및 컨테이너 제거 (데이터베이스 볼륨은 안전하게 보존됨)
|
||||
docker compose down
|
||||
|
||||
# (주의) 데이터베이스 볼륨까지 완전히 삭제하고 초기화할 때
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 6. 로컬 개발 및 실행 가이드 (Getting Started)
|
||||
|
||||
### 🅰️ Backend (Go REST API & Web Server) 개발 가이드
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
|
||||
# 데이터베이스 시드 및 마이그레이션 실행
|
||||
make migrate-seed
|
||||
|
||||
# 백엔드 서버 로컬 구동 (기본 포트: 8080)
|
||||
go run ./cmd/api --db anl.db --admin-token dev_admin_secret_token_12345
|
||||
```
|
||||
|
||||
### 🅱️ Frontend (Next.js 14) 개발 가이드
|
||||
```bash
|
||||
# 1. 참고용 웹 애플리케이션 디렉터리로 이동
|
||||
cd refer_landing_page
|
||||
|
||||
# 2. 의존성 패키지 설치
|
||||
# 종속성 설치
|
||||
npm install
|
||||
|
||||
# 3. 개발 서버 실행 (기본 포트: http://localhost:3000)
|
||||
# 개발 서버 실행 (Next.js dev 서버: http://localhost:3000)
|
||||
npm run dev
|
||||
|
||||
# 4. 프로덕션 빌드 테스트 (검증 시 사용)
|
||||
# 정적 익스포트 빌드 검증 (out/ 디렉터리 생성)
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 7. 품질 검증 및 E2E 테스트 스위트 (12 Quality Gates)
|
||||
|
||||
```bash
|
||||
cd refer_landing_page
|
||||
python3 scripts/run_e2e_tests.py
|
||||
```
|
||||
모든 12개 품질 게이트(Static Build Completeness, TypeScript, ESLint, SQLite Baseline, Live Unified Go Server Routes, DOM Content, Layout Architecture, WCAG Contrast 등)가 100% 무결하게 검증됩니다.
|
||||
|
||||
+9
-4
@@ -4,10 +4,10 @@
|
||||
| 항목 | 내용 |
|
||||
| :--- | :--- |
|
||||
| **문서 ID** | `REQ-ANL-WEB-001` |
|
||||
| **버전** | `2.0` |
|
||||
| **버전** | `2.1` |
|
||||
| **작성일** | 2026-08-24 |
|
||||
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
|
||||
| **상태** | 승인 (Approved) — v2.0에서 **DM-03 및 AC-17 개정**(Go/Gin 백엔드 REST API 연동 및 `force-dynamic` + `cache: 'no-store'` 기반 실시간 동적 런타임 페칭 도입, 6개 데이터 라우트 `ƒ Dynamic` 및 4개 프레임워크 라우트 `○ Static` 분리 확정). |
|
||||
| **상태** | 승인 (Approved) — v2.1에서 **`/console` 어드민 대시보드 및 백엔드 CRUD REST API(POST/PUT/DELETE)** 추가. Bearer Token(`ADMIN_TOKEN`) 기반 관리자 인증 도입 및 백오피스 `/console` UI 전용 라우트/컴포넌트 설계 반영. 공개 디자인 토큰 계약(AC-43~AC-48)의 적용 범위(공개 랜딩 페이지 전용) 명문화. |
|
||||
| **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 |
|
||||
| **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) |
|
||||
| **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 |
|
||||
@@ -287,7 +287,7 @@ Home의 슬로건 히어로를 **실제 연구 프로젝트 카드 뷰**로 전
|
||||
| :--- | :--- | :-- |
|
||||
| DM-01 | 콘텐츠 데이터는 페이지 컴포넌트에서 분리해 **`content/` 디렉터리의 타입 지정 모듈**로 이관한다 (`content/members.ts`, `content/publications.ts`, `content/lectures.ts`, `content/standards.ts`) | MUST |
|
||||
| DM-02 | 각 모듈은 명시적 TypeScript 인터페이스를 export 한다. `any` 사용 금지 | MUST |
|
||||
| DM-03 *(v2.0 개정)* | 데이터는 **실시간 동적 런타임 페칭(`force-dynamic`, `cache: 'no-store'`)**으로 백엔드 API(`http://localhost:8080/api/v1`)로부터 조회한다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다. 6개 데이터 라우트는 `ƒ Dynamic`으로 렌더링되며, 4개 프레임워크 라우트는 `○ Static`을 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST |
|
||||
| DM-03 *(v3.0 개정)* | 데이터는 **Next.js 정적 Export (`output: 'export'`)** 환경에서 클라이언트 사이드 런타임 페칭(`useEffect`, `cache: 'no-store'`)으로 동일 오리진 백엔드 API(`/api/v1`)로부터 조회한다. 모든 공개 라우트 및 콘솔 라우트는 정적 번들(`○ Static` / `● SSG`)로 생성되어 Go 백엔드 서버에서 단일 서빙되며, `ƒ Dynamic` 라우트는 0건이다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST |
|
||||
| DM-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST |
|
||||
|
||||
### 7.2 스키마 정의 (계약)
|
||||
@@ -563,6 +563,9 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
||||
- 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4).
|
||||
|
||||
**R-8.32 [필수 · 게이트 면제 형식 제한] *(v1.13 신설)*** 자동 게이트의 예외는 **`(파일, 행, 사유)` 3-튜플 등재로만** 허용하며, **라인 부분문자열 면제(`if "…" in line: continue`)를 금지**한다. 등재 항목이 실제 소스에 존재하지 않으면(고아 항목) 게이트는 **실패**해야 한다.
|
||||
|
||||
**R-8.33 [규정 · 디자인 시스템 적용 범위 및 관리자 콘솔 분리] *(v2.1 신설)*** AC-43, AC-44, AC-45, AC-47, C11-9 등 디자인 토큰 및 명암 대비 품질 게이트는 **공개 사용자용 랜딩 페이지(Public Site) 디자인 시스템의 무결성을 보장하기 위한 규격**이다. 비공개 백오피스 관리자 대시보드(`/console/*`)는 데이터 관리 및 CRUD 폼/테이블 조작성을 위해 독립된 콘솔 UI를 사용하며, 공개 사이트 전용 `text-white` 카운트 상한(AC-47) 및 강조색 수식자 제약에서 명시적으로 제외된다.
|
||||
- 자동 검증: **Gate 10 (`/console` 경로 제외)**.
|
||||
- 근거(실측): `run_e2e_tests.py`의 AC-44 구현은 면제 조항에 `"text-vermillion" in line`을 두었는데, 위반 판정 정규식이 `text-(cobalt|vermillion)`이므로 **vermillion으로 매치된 라인은 예외 없이 그 문자열을 포함**한다 — 즉 **AC-44의 vermillion 절반이 영구적으로 발화하지 않는다.** 실제 소스 4개 라인(`lectures:50`·`members:114`·`publications:72`·`DirectionsBlock:8`)과 회귀 주입 2건을 동일 로직에 투입해 **전부 면제됨**을 재현했다.
|
||||
- 나아가 `"text-3xl"`·`"Pillar #"` 등의 면제도 **라인 전체 부분문자열 검사**라, 삼항식 한 줄에 두 색이 섞이면 **cobalt 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다.
|
||||
- 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
|
||||
@@ -648,7 +651,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
||||
**품질 게이트**
|
||||
- [ ] AC-15 `npm run lint` 통과 (경고 0)
|
||||
- [ ] AC-16 `npx tsc --noEmit` 통과
|
||||
- [ ] AC-17 *(v2.0 개정)* `npm run build` 성공. 빌드 리포트에 **정확히 6개 데이터 라우트가 `ƒ (Dynamic)`**(`force-dynamic`)이고 **4개 프레임워크/메타데이터 라우트가 `○ (Static)`**으로 분리되어 생성됨을 확인한다 (§7.1 DM-03)
|
||||
- [ ] AC-17 *(v3.0 개정)* `npm run build` 성공. Next.js 정적 Export(`output: 'export'`)에 따라 빌드 산출물(`out/`)에 모든 공개 라우트 및 콘솔 라우트가 정적 파일(`○ Static` / `● SSG`)로 생성되며, `ƒ (Dynamic)` 라우트가 0건임을 확인한다 (§7.1 DM-03)
|
||||
- [ ] AC-18 First Load JS 증가분 ≤ 30 kB
|
||||
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
|
||||
- [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐)
|
||||
@@ -1050,3 +1053,5 @@ G-1은 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유
|
||||
| 1.13 | 2026-07-31 | claude (planner) | 테마 색상 통일성 검증 계획 수립 (`eaddc4f`→`082d71e` 4개 커밋). **Gate 0~10 전량 통과·빌드 exit 0인 상태에서 WCAG AA 미달 6건·통일성 결함 9건이 실재**함을 실측으로 확정 — 게이트 통과가 곧 오탐 신호였다. 원인은 게이트 **범위 결함 3종**: ⓐ **AC-44의 vermillion 절반이 구조적으로 무효**(면제 조항 `"text-vermillion" in line`이 판정 정규식과 항상 동시 매치 — 실소스 4라인 + 주입 2건으로 재현), ⓑ AC-45가 `-dark` 2종만 검사해 **`--cobalt` 반전 대비 1.00**(D-10 근본 원인의 재발)을 미검출, ⓒ AC-43이 소스의 전경/배경 쌍을 보지 않아 `bg-cobalt-dark text-white`(다크 2.85)를 통과. **R-8.30 신설**(비텍스트 UI 3:1 · 알파는 합성 후 산정 — `focus:ring-cobalt/50` **1.80** · `hover:border-cobalt/60` **1.95** 검출), **R-8.31 신설**(역할 시그니처 단위 색 통일 — 괘선 6:1 · 키커 11:1 · 배지 전경 6:4 · hover 테두리 4종 · 포커스 2종, 지도교수 이메일 1값이 3색 3대비), **R-8.32 신설**(게이트 면제는 `(파일,행,사유)` 등재만 허용 · 라인 부분문자열 면제 금지 — **N6과 동일 유형**). **R-8.5·R-8.26·R-8.28 개정**, **AC-42·43·44·45·47 개정**, **AC-48~AC-50 신설**(Gate 11 C11-1~8 · 폴트 인젝션 F1~F9 · `docs/DESIGN.md` 동기화 4건). **§8.1 색상표를 실제 토큰값 `#4E77FF`/`#345AD2`로 동기화**하고 **D-10 확정값을 #345AD2로 개정**(paper 4.76, 여유 0.26 → R-12). **D-12 신설**(🔴 Primary 적용의 판정 기준 — 라이트 테마에서 #4E77FF가 정적으로 보이는 곳이 **2곳뿐**이며 인지색은 #345AD2. 히어로 `Orchestration`이 R-8.5·DESIGN.md §2를 위반해 cobalt로 변경되어 한 페이지 안에서 규약이 분열), **D-13 신설**(🟡 `--vermillion` 반전 1.25). **A-1 `lectures:89` 4.16 미교정 잔존**·**A-2 `not-found.tsx:22` 4.16 신규 발견**(어떤 AC에도 없던 항목)·**A-7 `::selection` 4.16 미등재** 확정. **D-11 실측 갱신**(23건, 최저 3.77). 색 교체 5회 연속 대비표 미첨부(R-10). Job `43f3f74a` |
|
||||
| 1.14 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `4962a667`) 반영. **구조적 통찰 채택** — 다크 `--cobalt`만 단독 상향하면 다크 지면 대비가 DEFAULT 9.2 > `-dark` 5.96으로 **계층이 역전**됨을 실측 확인, v1.13 §7 각주의 누락을 인정하고 **R-8.33 신설**(토큰 등급 불변식). 다만 불변식을 **절대 휘도 순서가 아니라 테마 상대 대비**로 정식화 — 절대 순서의 테마 간 뒤집힘은 vermillion 쌍에서도 동일한 **정상 동작**이며 현행 8개 셀 전부에서 성립하므로, 절대 휘도로 판정하면 정상 상태를 오판한다. **제안 수치 미채택** — 주 권장값 `#85ACFF`의 반전 대비는 **1.73**으로 조정을 촉발한 R-8.28(≥2.0) 자체를 충족하지 못하며, 제안 표의 `#345AD2` L=0.095는 **폐기된 #32549E의 값**(실제 0.1270)이라 `#B0CDFF` 반전 대비 2.50도 실제 **3.68**이다. **이의제기 지점에서 더 큰 결함 발굴** — 다크 `--cobalt`(#4D77FF)는 `bg-paper` 위 **4.37**이므로 v1.12가 지시한 `text-cobalt-dark dark:text-cobalt` 라우팅이 **다크 절반을 DEFAULT로 되돌려 8곳이 AA 미달**이다(**AC-51 신설**). 원인은 R-8.26의 "`paper` 기준 산정" 원칙을 **본 계획자가 라이트 테마에만 적용한 오류**로, v1.11 §6.5와 같은 형태의 누락이다. 게이트도 AC-44 정규식의 **부정 후방탐색 `(?<!dark:)`로 다크 절반을 설계상 면제**하고 있었다(**G-7** — G-1·G-2·G-3에 이은 네 번째 "검사를 절반만 한다" 유형). **처방을 토큰 조정에서 `dark:` 변형 제거로 재설계** — `-dark`는 반전 토큰이므로 `dark:` 없이 4개 셀 전부(5.26/4.76/6.57/5.96)를 통과한다. **R-8.29 강화**(강조색 `dark:` 금지 · 정본 패턴 3종 · 현행 위반 **38건**), **R-8.26·AC-43·AC-44·AC-48(C11-9)·AC-49(F10·F11, 총 11건) 개정**. **D-14 신설**(🔴 `--cobalt` 반전 1.00 — A안 브랜드 고정 예외 권고 / B안 대칭 상향 정정 수치 `#A8BCFF`+`#C7D4FF`). A안은 본 계획자가 작성한 R-8.28의 완화를 포함하므로 **cline·GM 별도 심사 대상**으로 명시했고, 안전망 유지 근거로 `#1B3A8A` 역주입 시 AC-43 즉시 실패(1.80)를 제시했다. Job `d35bc3e7` |
|
||||
| 2.0 | 2026-08-24 | claude (planner) / agy (creator) | **실시간 동적 런타임 페칭 아키텍처 도입 (DM-03·AC-17·R-08 개정)**. Go/Gin 백엔드 REST API(`http://localhost:8080/api/v1`) 연동 및 `force-dynamic` + `cache: 'no-store'` 적용으로 전 페이지 실시간 데이터 바인딩 지원. 6개 데이터 라우트를 `ƒ (Dynamic)`으로, 4개 프레임워크 라우트를 `○ (Static)`으로 분리. 기존 `content/*.ts` 정적 상수를 장애 시 그레이스풀 폴백 데이터셋으로 유지. E2E 테스트 게이트 2, 4, 7, 8을 라이브 서버 기반 검증으로 리팩터링. Job `258f6ff7` |
|
||||
| 3.0 | 2026-08-25 | agy (creator) / claude (planner) | **단일 통합 Go 백엔드 서빙 아키텍처 (Unified Single Go Backend Serving Architecture) 마이그레이션 완료**. Next.js 정적 익스포트(`output: "export"`, `trailingSlash: true`) 및 클라이언트 사이드 데이터 페칭(CSR `useEffect` + 동일 오리진 상대 경로 `/api/v1` 기본값) 도입. 전 공용 라우트(`○`/`●`) 정적 HTML 셸 및 `/app/web` 번들링. Go Gin 라우터에 정적 에셋 서빙(`/_next`, `/robots.txt`, `/sitemap.xml` 등), `/intro` 308 영구 리다이렉트, 및 `NoRoute` 정적 HTML/SPA 404 폴백 핸들러 구현. 3단계 통합 Multi-stage `backend/Dockerfile`(Node 빌드 + Go 빌드 + 최소 Alpine 런타임) 및 단일 서비스 `anl-app` `docker-compose.yml` 구축. 전 품질 게이트(Gate 0~11) 100% PASS 검증. Job `d2035abb` |
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
*.db
|
||||
*.log
|
||||
.git
|
||||
.env
|
||||
@@ -15,5 +15,8 @@ GIN_MODE=release
|
||||
# Allowed CORS Origins (Comma separated, or * for all)
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
|
||||
# Admin console secret bearer token for POST/PUT/DELETE mutations (Required in release mode)
|
||||
ADMIN_TOKEN=your_secure_admin_bearer_token_here
|
||||
|
||||
# Automatically run SQL schema migrations on startup (true | false)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
/export-content
|
||||
/api
|
||||
/seed
|
||||
/web/
|
||||
|
||||
# SQLite databases and WAL journal files
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
!db/backup/*.sql
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
|
||||
+25
-30
@@ -1,51 +1,47 @@
|
||||
# ==============================================================================
|
||||
# Stage 1: Build the Go Binaries
|
||||
# Stage 1: Build the static frontend export
|
||||
# ==============================================================================
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY refer_landing_page/package.json refer_landing_page/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY refer_landing_page/ .
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Build the Go binaries
|
||||
# ==============================================================================
|
||||
FROM golang:1.26-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install git and certificates
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# Download dependencies
|
||||
COPY go.mod go.sum ./
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy backend source code
|
||||
COPY . .
|
||||
|
||||
# Build pure-Go static binaries
|
||||
COPY backend/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api ./cmd/api && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/seed ./cmd/seed && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/export-content ./cmd/export-content
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Minimal Runtime Image
|
||||
# Stage 3: Minimal Alpine runtime — single container, single process
|
||||
# ==============================================================================
|
||||
FROM alpine:3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates and sqlite cli for maintenance
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
|
||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
|
||||
# Create persistent database storage directory
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
|
||||
|
||||
# Copy built binaries from builder
|
||||
COPY --from=builder /bin/api /app/bin/api
|
||||
COPY --from=builder /bin/seed /app/bin/seed
|
||||
COPY --from=builder /bin/export-content /app/bin/export-content
|
||||
COPY --from=backend-builder /bin/api /app/bin/api
|
||||
COPY --from=backend-builder /bin/seed /app/bin/seed
|
||||
COPY --from=backend-builder /bin/export-content /app/bin/export-content
|
||||
COPY --from=backend-builder /app/seed /app/seed
|
||||
COPY --from=backend-builder /app/.env.example /app/.env
|
||||
COPY --from=frontend-builder --chown=appuser:appgroup /app/frontend/out /app/web
|
||||
|
||||
# Copy seed data
|
||||
COPY --from=builder /app/seed /app/seed
|
||||
|
||||
# Copy environment template
|
||||
COPY --from=builder /app/.env.example /app/.env
|
||||
|
||||
# Set environment defaults for container
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
DB_PATH=/app/data/anl.db \
|
||||
@@ -54,7 +50,6 @@ ENV HOST=0.0.0.0 \
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER appuser
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
|
||||
@@ -37,5 +37,11 @@ vet:
|
||||
test: fmt-check vet
|
||||
go test -v ./...
|
||||
|
||||
backup-db:
|
||||
@mkdir -p db/backup
|
||||
sqlite3 $(DB_PATH) ".backup 'db/backup/anl.db'"
|
||||
sqlite3 $(DB_PATH) ".dump" > db/backup/anl_dump.sql
|
||||
@echo "✅ Database backed up to db/backup/anl.db and db/backup/anl_dump.sql"
|
||||
|
||||
clean:
|
||||
rm -rf $(BIN_DIR) *.db*
|
||||
|
||||
+17
-1
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
@@ -19,6 +22,7 @@ func main() {
|
||||
hostFlag := flag.String("host", "", "Host for the Gin server to bind on (overrides HOST)")
|
||||
dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)")
|
||||
ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)")
|
||||
adminTokenFlag := flag.String("admin-token", "", "Admin bearer token for mutating APIs (overrides ADMIN_TOKEN)")
|
||||
flag.Parse()
|
||||
|
||||
if *portFlag > 0 {
|
||||
@@ -33,6 +37,18 @@ func main() {
|
||||
if *ginModeFlag != "" {
|
||||
cfg.GinMode = *ginModeFlag
|
||||
}
|
||||
if *adminTokenFlag != "" {
|
||||
cfg.AdminToken = strings.Trim(*adminTokenFlag, " \"'\r\n\t")
|
||||
}
|
||||
|
||||
if cfg.AdminToken == "" {
|
||||
if cfg.GinMode == gin.ReleaseMode || cfg.GinMode == "release" {
|
||||
log.Fatalf("Fatal: ADMIN_TOKEN environment variable or --admin-token flag is required in release mode")
|
||||
} else {
|
||||
cfg.AdminToken = "dev-ephemeral-" + hex.EncodeToString([]byte(time.Now().Format("20060102150405")))
|
||||
log.Printf("⚠️ WARNING: ADMIN_TOKEN not configured. Generated ephemeral dev token: %s", cfg.AdminToken)
|
||||
}
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.GinMode)
|
||||
|
||||
@@ -50,7 +66,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
r := router.SetupRouter(database, cfg.CORSAllowOrigins)
|
||||
r := router.SetupRouter(database, cfg.AdminToken, cfg.CORSAllowOrigins)
|
||||
|
||||
addr := cfg.Address()
|
||||
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
|
||||
|
||||
@@ -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
|
||||
CORSAllowOrigins string
|
||||
AutoMigrate bool
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
|
||||
@@ -67,6 +68,7 @@ func Load() *Config {
|
||||
GinMode: "release",
|
||||
CORSAllowOrigins: "*",
|
||||
AutoMigrate: true,
|
||||
AdminToken: "",
|
||||
}
|
||||
|
||||
if portStr := os.Getenv("PORT"); portStr != "" {
|
||||
@@ -91,6 +93,10 @@ func Load() *Config {
|
||||
cfg.CORSAllowOrigins = corsStr
|
||||
}
|
||||
|
||||
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
||||
cfg.AdminToken = strings.Trim(token, " \"'\r\n\t")
|
||||
}
|
||||
|
||||
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestConfigDefaults(t *testing.T) {
|
||||
_ = os.Unsetenv("GIN_MODE")
|
||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||
|
||||
cfg := config.Load()
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
@@ -26,6 +27,7 @@ func TestConfigDefaults(t *testing.T) {
|
||||
assert.Equal(t, "release", cfg.GinMode)
|
||||
assert.Equal(t, "*", cfg.CORSAllowOrigins)
|
||||
assert.True(t, cfg.AutoMigrate)
|
||||
assert.Equal(t, "", cfg.AdminToken)
|
||||
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
||||
_ = os.Setenv("GIN_MODE", "debug")
|
||||
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
|
||||
_ = os.Setenv("AUTO_MIGRATE", "false")
|
||||
_ = os.Setenv("ADMIN_TOKEN", "super-secret-token")
|
||||
defer func() {
|
||||
_ = os.Unsetenv("PORT")
|
||||
_ = os.Unsetenv("HOST")
|
||||
@@ -43,6 +46,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
||||
_ = os.Unsetenv("GIN_MODE")
|
||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||
}()
|
||||
|
||||
cfg := config.Load()
|
||||
@@ -52,6 +56,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
||||
assert.Equal(t, "debug", cfg.GinMode)
|
||||
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
|
||||
assert.False(t, cfg.AutoMigrate)
|
||||
assert.Equal(t, "super-secret-token", cfg.AdminToken)
|
||||
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -23,6 +26,61 @@ func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
|
||||
httpx.OK(c, projects)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) CreateResearchProject(c *gin.Context) {
|
||||
var req models.ResearchProject
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
|
||||
httpx.BadRequest(c, "slug, title, and abstract are required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateResearchProject(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create research project: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) UpdateResearchProject(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid project ID")
|
||||
return
|
||||
}
|
||||
var req models.ResearchProject
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
|
||||
httpx.BadRequest(c, "slug, title, and abstract are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateResearchProject(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update research project: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) DeleteResearchProject(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid project ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteResearchProject(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete research project: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
||||
areas, err := h.repo.GetResearchAreas(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -32,6 +90,61 @@ func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
||||
httpx.OK(c, areas)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) CreateResearchArea(c *gin.Context) {
|
||||
var req models.ResearchArea
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_en is required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateResearchArea(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create research area: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) UpdateResearchArea(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid area ID")
|
||||
return
|
||||
}
|
||||
var req models.ResearchArea
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_en is required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateResearchArea(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update research area: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) DeleteResearchArea(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid area ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteResearchArea(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete research area: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
|
||||
stats, err := h.repo.GetStatsSummary(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validTerms = map[string]bool{
|
||||
"Spring": true,
|
||||
"Fall": true,
|
||||
}
|
||||
|
||||
type LecturesHandler struct {
|
||||
repo *repository.LecturesRepository
|
||||
}
|
||||
@@ -22,3 +30,119 @@ func (h *LecturesHandler) GetSemesters(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, semesters)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) CreateSemester(c *gin.Context) {
|
||||
var req models.Semester
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Year < 1970 || !validTerms[req.Term] {
|
||||
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateSemester(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create semester: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) UpdateSemester(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid semester ID")
|
||||
return
|
||||
}
|
||||
var req models.Semester
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Year < 1970 || !validTerms[req.Term] {
|
||||
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateSemester(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update semester: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) DeleteSemester(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid semester ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteSemester(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete semester: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) CreateCourse(c *gin.Context) {
|
||||
semesterID, err := strconv.Atoi(c.Param("semesterId"))
|
||||
if err != nil || semesterID <= 0 {
|
||||
httpx.BadRequest(c, "Invalid semesterId parameter")
|
||||
return
|
||||
}
|
||||
var req models.Course
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "code and name_en are required")
|
||||
return
|
||||
}
|
||||
req.SemesterID = semesterID
|
||||
id, err := h.repo.CreateCourse(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create course: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) UpdateCourse(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid course ID")
|
||||
return
|
||||
}
|
||||
var req models.Course
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "code and name_en are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateCourse(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update course: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) DeleteCourse(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid course ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteCourse(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete course: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validMemberDegrees = map[string]bool{
|
||||
"Professor": true,
|
||||
"PhD": true,
|
||||
"PhDCandidate": true,
|
||||
"PhDStudent": true,
|
||||
"MSCandidate": true,
|
||||
"MSStudent": true,
|
||||
}
|
||||
|
||||
var validAlumniDegrees = map[string]bool{
|
||||
"PhD": true,
|
||||
"MS": true,
|
||||
}
|
||||
|
||||
type MembersHandler struct {
|
||||
repo *repository.MembersRepository
|
||||
}
|
||||
@@ -23,6 +40,69 @@ func (h *MembersHandler) GetMembers(c *gin.Context) {
|
||||
httpx.OK(c, members)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) CreateMember(c *gin.Context) {
|
||||
var req models.Member
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_ko and name_en are required")
|
||||
return
|
||||
}
|
||||
if !validMemberDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateMember(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create member: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) UpdateMember(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid member ID")
|
||||
return
|
||||
}
|
||||
var req models.Member
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_ko and name_en are required")
|
||||
return
|
||||
}
|
||||
if !validMemberDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateMember(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update member: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) DeleteMember(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid member ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteMember(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete member: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
||||
alumni, err := h.repo.GetAlumni(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -31,3 +111,66 @@ func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, alumni)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) CreateAlumnus(c *gin.Context) {
|
||||
var req models.Alumnus
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
|
||||
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
|
||||
return
|
||||
}
|
||||
if !validAlumniDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: PhD, MS")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateAlumnus(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create alumnus: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) UpdateAlumnus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid alumnus ID")
|
||||
return
|
||||
}
|
||||
var req models.Alumnus
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
|
||||
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
|
||||
return
|
||||
}
|
||||
if !validAlumniDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: PhD, MS")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateAlumnus(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update alumnus: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) DeleteAlumnus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid alumnus ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteAlumnus(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete alumnus: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validPubCategories = map[string]bool{
|
||||
"intl-journal-conf": true,
|
||||
"domestic-journal-conf": true,
|
||||
}
|
||||
|
||||
type PublicationsHandler struct {
|
||||
repo *repository.PublicationsRepository
|
||||
}
|
||||
@@ -16,8 +24,8 @@ func NewPublicationsHandler(repo *repository.PublicationsRepository) *Publicatio
|
||||
|
||||
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||
category := c.Query("category")
|
||||
if category != "" && category != "intl-journal-conf" && category != "domestic-journal-conf" {
|
||||
httpx.BadRequest(c, "Invalid category parameter: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
if category != "" && !validPubCategories[category] {
|
||||
httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,13 +37,76 @@ func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||
httpx.OK(c, pubs)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
||||
highlights, err := h.repo.GetHighlights(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve highlights: "+err.Error())
|
||||
func (h *PublicationsHandler) CreatePublication(c *gin.Context) {
|
||||
var req models.Publication
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, highlights)
|
||||
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, venue, and published_at are required")
|
||||
return
|
||||
}
|
||||
if !validPubCategories[req.Category] {
|
||||
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreatePublication(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create publication: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) UpdatePublication(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid publication ID")
|
||||
return
|
||||
}
|
||||
var req models.Publication
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, venue, and published_at are required")
|
||||
return
|
||||
}
|
||||
if !validPubCategories[req.Category] {
|
||||
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdatePublication(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update publication: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) DeletePublication(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid publication ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeletePublication(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete publication: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
||||
pubs, err := h.repo.GetHighlights(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve highlight publications: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, pubs)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
||||
@@ -46,3 +117,58 @@ func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, patents)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) CreatePatent(c *gin.Context) {
|
||||
var req models.Patent
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreatePatent(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create patent: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) UpdatePatent(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid patent ID")
|
||||
return
|
||||
}
|
||||
var req models.Patent
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdatePatent(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update patent: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) DeletePatent(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid patent ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeletePatent(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete patent: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validScopes = map[string]bool{
|
||||
"international": true,
|
||||
"domestic": true,
|
||||
}
|
||||
|
||||
var validDocStatuses = map[string]bool{
|
||||
"IS": true,
|
||||
"TS": true,
|
||||
"TR": true,
|
||||
"CDV": true,
|
||||
"WDTR": true,
|
||||
"InProgress": true,
|
||||
}
|
||||
|
||||
type StandardizationHandler struct {
|
||||
repo *repository.StandardizationRepository
|
||||
}
|
||||
@@ -22,3 +39,135 @@ func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, bodies)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) CreateStandardsBody(c *gin.Context) {
|
||||
var req models.StandardsBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Org == "" || req.FullName == "" || req.Period == "" {
|
||||
httpx.BadRequest(c, "org, full_name, and period are required")
|
||||
return
|
||||
}
|
||||
if !validScopes[req.Scope] {
|
||||
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateStandardsBody(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create standards body: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) UpdateStandardsBody(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid standards body ID")
|
||||
return
|
||||
}
|
||||
var req models.StandardsBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Org == "" || req.FullName == "" || req.Period == "" {
|
||||
httpx.BadRequest(c, "org, full_name, and period are required")
|
||||
return
|
||||
}
|
||||
if !validScopes[req.Scope] {
|
||||
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateStandardsBody(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update standards body: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) DeleteStandardsBody(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid standards body ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteStandardsBody(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete standards body: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) CreateStandardDocument(c *gin.Context) {
|
||||
bodyID, err := strconv.Atoi(c.Param("bodyId"))
|
||||
if err != nil || bodyID <= 0 {
|
||||
httpx.BadRequest(c, "Invalid bodyId parameter")
|
||||
return
|
||||
}
|
||||
var req models.StandardDocument
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
|
||||
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
|
||||
return
|
||||
}
|
||||
if !validDocStatuses[req.Status] {
|
||||
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
|
||||
return
|
||||
}
|
||||
req.BodyID = bodyID
|
||||
id, err := h.repo.CreateStandardDocument(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create standard document: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) UpdateStandardDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid document ID")
|
||||
return
|
||||
}
|
||||
var req models.StandardDocument
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
|
||||
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
|
||||
return
|
||||
}
|
||||
if !validDocStatuses[req.Status] {
|
||||
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateStandardDocument(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update standard document: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) DeleteStandardDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid document ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteStandardDocument(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete standard document: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
Error(c, http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var projects []models.ResearchProject
|
||||
projects := make([]models.ResearchProject, 0)
|
||||
for rows.Next() {
|
||||
var p models.ResearchProject
|
||||
var kwJSON string
|
||||
@@ -55,6 +55,54 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *HomeRepository) CreateResearchProject(ctx context.Context, p models.ResearchProject) (int, error) {
|
||||
kwBytes, _ := json.Marshal(p.Keywords)
|
||||
if p.Keywords == nil {
|
||||
kwBytes = []byte("[]")
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert research project: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) UpdateResearchProject(ctx context.Context, id int, p models.ResearchProject) error {
|
||||
kwBytes, _ := json.Marshal(p.Keywords)
|
||||
if p.Keywords == nil {
|
||||
kwBytes = []byte("[]")
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE research_projects
|
||||
SET slug = ?, title = ?, abstract = ?, keywords = ?, organization = ?, standards_org = ?, period = ?, funder = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update research project: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) DeleteResearchProject(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM research_projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete research project: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
|
||||
query := `
|
||||
SELECT id, name_en, name_kr, display_order
|
||||
@@ -67,7 +115,7 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var areas []models.ResearchArea
|
||||
areas := make([]models.ResearchArea, 0)
|
||||
for rows.Next() {
|
||||
var a models.ResearchArea
|
||||
if err := rows.Scan(&a.ID, &a.NameEn, &a.NameKr, &a.DisplayOrder); err != nil {
|
||||
@@ -78,6 +126,166 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
||||
return areas, rows.Err()
|
||||
}
|
||||
|
||||
func (r *HomeRepository) normalizeResearchAreasOrder(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM research_areas ORDER BY display_order ASC, id ASC`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query areas for normalization: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return fmt.Errorf("scan area id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for idx, id := range ids {
|
||||
newOrder := idx + 1
|
||||
_, err := tx.ExecContext(ctx, `UPDATE research_areas SET display_order = ? WHERE id = ?`, newOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update area %d to order %d: %w", id, newOrder, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if a.DisplayOrder > 0 {
|
||||
// Shift existing research areas with display_order >= target order by +1
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE research_areas
|
||||
SET display_order = display_order + 1
|
||||
WHERE display_order >= ?
|
||||
`, a.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("shift research areas display_order: %w", err)
|
||||
}
|
||||
} else {
|
||||
var maxOrder sql.NullInt64
|
||||
_ = tx.QueryRowContext(ctx, `SELECT MAX(display_order) FROM research_areas`).Scan(&maxOrder)
|
||||
if maxOrder.Valid {
|
||||
a.DisplayOrder = int(maxOrder.Int64) + 1
|
||||
} else {
|
||||
a.DisplayOrder = 1
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||
VALUES (?, ?, ?)
|
||||
`, a.NameEn, a.NameKr, a.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert research area: %w", err)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get inserted area id: %w", err)
|
||||
}
|
||||
|
||||
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
|
||||
return 0, fmt.Errorf("normalize display_order: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
return int(id), nil
|
||||
}
|
||||
|
||||
func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var oldOrder int
|
||||
err = tx.QueryRowContext(ctx, `SELECT display_order FROM research_areas WHERE id = ?`, id).Scan(&oldOrder)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return fmt.Errorf("get old display_order: %w", err)
|
||||
}
|
||||
|
||||
if a.DisplayOrder > 0 && a.DisplayOrder != oldOrder {
|
||||
if oldOrder < a.DisplayOrder {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE research_areas
|
||||
SET display_order = display_order - 1
|
||||
WHERE display_order > ? AND display_order <= ? AND id != ?
|
||||
`, oldOrder, a.DisplayOrder, id)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE research_areas
|
||||
SET display_order = display_order + 1
|
||||
WHERE display_order >= ? AND display_order < ? AND id != ?
|
||||
`, a.DisplayOrder, oldOrder, id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shift research areas display_order on update: %w", err)
|
||||
}
|
||||
} else if a.DisplayOrder <= 0 {
|
||||
a.DisplayOrder = oldOrder
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE research_areas
|
||||
SET name_en = ?, name_kr = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, a.NameEn, a.NameKr, a.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update research area: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
|
||||
return fmt.Errorf("normalize display_order on update: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete research area: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
|
||||
return fmt.Errorf("normalize display_order on delete: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
|
||||
var summary models.StatsSummary
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ func NewLecturesRepository(db *sql.DB) *LecturesRepository {
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
||||
// Query semesters ordered by year DESC and Fall before Spring within same year
|
||||
semQuery := `
|
||||
SELECT id, year, term
|
||||
FROM semesters
|
||||
@@ -29,8 +28,8 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
||||
}
|
||||
defer semRows.Close()
|
||||
|
||||
var semesters []models.SemesterWithCourses
|
||||
semMap := make(map[int]int) // semester_id -> index in slice
|
||||
semesters := make([]models.SemesterWithCourses, 0)
|
||||
semMap := make(map[int]int)
|
||||
|
||||
for semRows.Next() {
|
||||
var s models.SemesterWithCourses
|
||||
@@ -45,7 +44,6 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query courses ordered by display_order ASC, id ASC
|
||||
courseQuery := `
|
||||
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
||||
FROM courses
|
||||
@@ -69,3 +67,83 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
||||
|
||||
return semesters, courseRows.Err()
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) CreateSemester(ctx context.Context, s models.Semester) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO semesters (year, term)
|
||||
VALUES (?, ?)
|
||||
`, s.Year, s.Term)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert semester: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) UpdateSemester(ctx context.Context, id int, s models.Semester) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE semesters
|
||||
SET year = ?, term = ?
|
||||
WHERE id = ?
|
||||
`, s.Year, s.Term, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update semester: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) DeleteSemester(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM semesters WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete semester: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) CreateCourse(ctx context.Context, c models.Course) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, c.SemesterID, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert course: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) UpdateCourse(ctx context.Context, id int, c models.Course) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE courses
|
||||
SET code = ?, name_en = ?, name_kr = ?, note = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update course: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) DeleteCourse(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM courses WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete course: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []models.Member
|
||||
members := make([]models.Member, 0)
|
||||
for rows.Next() {
|
||||
var m models.Member
|
||||
if err := rows.Scan(
|
||||
@@ -42,6 +42,46 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MembersRepository) CreateMember(ctx context.Context, m models.Member) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert member: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) UpdateMember(ctx context.Context, id int, m models.Member) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE members
|
||||
SET name_ko = ?, name_en = ?, degree = ?, affiliation = ?, email = ?, is_advisor = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update member: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) DeleteMember(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM members WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete member: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) {
|
||||
query := `
|
||||
SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at
|
||||
@@ -54,7 +94,7 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alumni []models.Alumnus
|
||||
alumni := make([]models.Alumnus, 0)
|
||||
for rows.Next() {
|
||||
var a models.Alumnus
|
||||
if err := rows.Scan(
|
||||
@@ -67,3 +107,43 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
|
||||
}
|
||||
return alumni, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MembersRepository) CreateAlumnus(ctx context.Context, a models.Alumnus) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert alumnus: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) UpdateAlumnus(ctx context.Context, id int, a models.Alumnus) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE alumni
|
||||
SET name_ko = ?, name_en = ?, degree = ?, graduated_at = ?, major = ?, current_position = ?
|
||||
WHERE id = ?
|
||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update alumnus: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) DeleteAlumnus(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM alumni WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete alumnus: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pubs []models.Publication
|
||||
pubs := make([]models.Publication, 0)
|
||||
for rows.Next() {
|
||||
var p models.Publication
|
||||
if err := rows.Scan(
|
||||
@@ -48,6 +48,46 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
||||
return pubs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) CreatePublication(ctx context.Context, p models.Publication) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert publication: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) UpdatePublication(ctx context.Context, id int, p models.Publication) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE publications
|
||||
SET category = ?, title = ?, authors = ?, venue = ?, volume = ?, published_at = ?, kci = ?, doi = ?, is_highlight = ?
|
||||
WHERE id = ?
|
||||
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update publication: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) DeletePublication(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM publications WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete publication: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) {
|
||||
query := `
|
||||
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
|
||||
@@ -61,7 +101,7 @@ func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Pu
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pubs []models.Publication
|
||||
pubs := make([]models.Publication, 0)
|
||||
for rows.Next() {
|
||||
var p models.Publication
|
||||
if err := rows.Scan(
|
||||
@@ -87,7 +127,7 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var patents []models.Patent
|
||||
patents := make([]models.Patent, 0)
|
||||
for rows.Next() {
|
||||
var p models.Patent
|
||||
if err := rows.Scan(
|
||||
@@ -100,3 +140,43 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
||||
}
|
||||
return patents, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) CreatePatent(ctx context.Context, p models.Patent) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert patent: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) UpdatePatent(ctx context.Context, id int, p models.Patent) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE patents
|
||||
SET title = ?, inventors = ?, application_no = ?, application_at = ?, registration_no = ?, registration_at = ?, country = ?, published_at = ?
|
||||
WHERE id = ?
|
||||
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update patent: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) DeletePatent(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM patents WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete patent: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
||||
// Query bodies ordered by display_order
|
||||
bodyQuery := `
|
||||
SELECT id, org, full_name, scope, period, role, display_order
|
||||
FROM standards_bodies
|
||||
@@ -29,8 +28,8 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
}
|
||||
defer bodyRows.Close()
|
||||
|
||||
var bodies []models.StandardsBodyWithProjects
|
||||
bodyMap := make(map[int]int) // body_id -> index in bodies
|
||||
bodies := make([]models.StandardsBodyWithProjects, 0)
|
||||
bodyMap := make(map[int]int)
|
||||
|
||||
for bodyRows.Next() {
|
||||
var b models.StandardsBodyWithProjects
|
||||
@@ -45,7 +44,6 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query documents ordered by body_id, and preserves curated insertion order with MIN(id) ASC
|
||||
docQuery := `
|
||||
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
||||
FROM standard_documents
|
||||
@@ -57,13 +55,12 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
}
|
||||
defer docRows.Close()
|
||||
|
||||
// Group documents into projects under each body preserving curated order
|
||||
type projectKey struct {
|
||||
bodyID int
|
||||
wg string
|
||||
projectName string
|
||||
}
|
||||
projectMap := make(map[projectKey]int) // projectKey -> index in bodies[bIdx].Projects
|
||||
projectMap := make(map[projectKey]int)
|
||||
|
||||
for docRows.Next() {
|
||||
var d models.StandardDocument
|
||||
@@ -93,3 +90,83 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
|
||||
return bodies, docRows.Err()
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) CreateStandardsBody(ctx context.Context, b models.StandardsBody) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert standards body: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) UpdateStandardsBody(ctx context.Context, id int, b models.StandardsBody) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE standards_bodies
|
||||
SET org = ?, full_name = ?, scope = ?, period = ?, role = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update standards body: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) DeleteStandardsBody(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM standards_bodies WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete standards body: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) CreateStandardDocument(ctx context.Context, d models.StandardDocument) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, d.BodyID, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert standard document: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) UpdateStandardDocument(ctx context.Context, id int, d models.StandardDocument) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE standard_documents
|
||||
SET wg = ?, project_name = ?, title = ?, doc_ref = ?, status = ?, published_at = ?
|
||||
WHERE id = ?
|
||||
`, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update standard document: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) DeleteStandardDocument(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM standard_documents WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete standard document: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package router
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/handlers"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
@@ -10,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
||||
func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
allowOrigin := "*"
|
||||
@@ -44,7 +47,7 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
||||
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
||||
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
||||
|
||||
// API v1 Group
|
||||
// API v1 Public Group (Read-only)
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
@@ -72,5 +75,109 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
||||
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
|
||||
}
|
||||
|
||||
// API v1 Admin Group (Mutating operations requiring Bearer token)
|
||||
admin := v1.Group("")
|
||||
admin.Use(httpx.RequireAdminToken(adminToken))
|
||||
{
|
||||
admin.GET("/admin/verify", func(c *gin.Context) {
|
||||
httpx.OK(c, gin.H{"status": "authenticated"})
|
||||
})
|
||||
|
||||
// Research Projects
|
||||
admin.POST("/research-projects", homeHandler.CreateResearchProject)
|
||||
admin.PUT("/research-projects/:id", homeHandler.UpdateResearchProject)
|
||||
admin.DELETE("/research-projects/:id", homeHandler.DeleteResearchProject)
|
||||
|
||||
// Research Areas
|
||||
admin.POST("/research-areas", homeHandler.CreateResearchArea)
|
||||
admin.PUT("/research-areas/:id", homeHandler.UpdateResearchArea)
|
||||
admin.DELETE("/research-areas/:id", homeHandler.DeleteResearchArea)
|
||||
|
||||
// Members & Alumni
|
||||
admin.POST("/members", membersHandler.CreateMember)
|
||||
admin.PUT("/members/:id", membersHandler.UpdateMember)
|
||||
admin.DELETE("/members/:id", membersHandler.DeleteMember)
|
||||
|
||||
admin.POST("/alumni", membersHandler.CreateAlumnus)
|
||||
admin.PUT("/alumni/:id", membersHandler.UpdateAlumnus)
|
||||
admin.DELETE("/alumni/:id", membersHandler.DeleteAlumnus)
|
||||
|
||||
// Publications & Patents
|
||||
admin.POST("/publications", pubsHandler.CreatePublication)
|
||||
admin.PUT("/publications/:id", pubsHandler.UpdatePublication)
|
||||
admin.DELETE("/publications/:id", pubsHandler.DeletePublication)
|
||||
|
||||
admin.POST("/patents", pubsHandler.CreatePatent)
|
||||
admin.PUT("/patents/:id", pubsHandler.UpdatePatent)
|
||||
admin.DELETE("/patents/:id", pubsHandler.DeletePatent)
|
||||
|
||||
// Lectures (Semesters & Courses)
|
||||
admin.POST("/semesters", lecturesHandler.CreateSemester)
|
||||
admin.PUT("/semesters/:id", lecturesHandler.UpdateSemester)
|
||||
admin.DELETE("/semesters/:id", lecturesHandler.DeleteSemester)
|
||||
|
||||
admin.POST("/semesters/:semesterId/courses", lecturesHandler.CreateCourse)
|
||||
admin.PUT("/courses/:id", lecturesHandler.UpdateCourse)
|
||||
admin.DELETE("/courses/:id", lecturesHandler.DeleteCourse)
|
||||
|
||||
// Standardization (Bodies & Documents)
|
||||
admin.POST("/standards-bodies", standardsHandler.CreateStandardsBody)
|
||||
admin.PUT("/standards-bodies/:id", standardsHandler.UpdateStandardsBody)
|
||||
admin.DELETE("/standards-bodies/:id", standardsHandler.DeleteStandardsBody)
|
||||
|
||||
admin.POST("/standards-bodies/:bodyId/documents", standardsHandler.CreateStandardDocument)
|
||||
admin.PUT("/standard-documents/:id", standardsHandler.UpdateStandardDocument)
|
||||
admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument)
|
||||
}
|
||||
|
||||
// 308 Permanent Redirect for /intro -> / (H-11 / IA-02)
|
||||
r.GET("/intro", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusPermanentRedirect, "/")
|
||||
})
|
||||
|
||||
// Static assets serving
|
||||
r.Static("/_next", "./web/_next")
|
||||
r.StaticFile("/favicon.ico", "./web/favicon.ico")
|
||||
r.StaticFile("/robots.txt", "./web/robots.txt")
|
||||
r.StaticFile("/sitemap.xml", "./web/sitemap.xml")
|
||||
r.StaticFile("/icon.svg", "./web/icon.svg")
|
||||
r.Static("/fonts", "./web/fonts")
|
||||
|
||||
// NoRoute fallback for static exported HTML & 404
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Never let a mistyped/unknown API path fall through to HTML.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
httpx.NotFound(c, "Route not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Try an exact static file (e.g. an asset with an extension)
|
||||
candidate := filepath.Join("./web", filepath.Clean(path))
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
c.File(candidate)
|
||||
return
|
||||
}
|
||||
|
||||
// Try <path>/index.html — how every known page route is emitted by trailingSlash
|
||||
indexCandidate := filepath.Join("./web", filepath.Clean(path), "index.html")
|
||||
if _, err := os.Stat(indexCandidate); err == nil {
|
||||
c.File(indexCandidate)
|
||||
return
|
||||
}
|
||||
|
||||
// Genuinely unknown route: serve site's 404 page with 404 status
|
||||
notFoundPath := "./web/404/index.html"
|
||||
if _, err := os.Stat(notFoundPath); err != nil {
|
||||
notFoundPath = "./web/404.html"
|
||||
}
|
||||
if notFoundBytes, err := os.ReadFile(notFoundPath); err == nil {
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", notFoundBytes)
|
||||
} else {
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
}
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
@@ -18,8 +20,9 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const testAdminToken = "test-secret-token"
|
||||
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
// In-memory or temporary file sqlite DB
|
||||
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
||||
database, err := db.Connect(tmpDB)
|
||||
require.NoError(t, err)
|
||||
@@ -27,7 +30,6 @@ func setupTestDB(t *testing.T) *sql.DB {
|
||||
err = db.RunMigrations(database)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Seed test data from ../../seed/data
|
||||
seedDir := filepath.Join("..", "..", "seed", "data")
|
||||
seedDatabase(t, database, seedDir)
|
||||
|
||||
@@ -65,45 +67,75 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
||||
// 2. Areas
|
||||
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
||||
require.NoError(t, err)
|
||||
var areas []models.ResearchArea
|
||||
var areas []struct {
|
||||
En string `json:"en"`
|
||||
Kr *string `json:"kr"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(aData, &areas))
|
||||
for _, a := range areas {
|
||||
_, err := tx.Exec(`INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?)`, a.NameEn, a.NameKr, a.DisplayOrder)
|
||||
for idx, a := range areas {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||
VALUES (?, ?, ?)
|
||||
`, a.En, a.Kr, idx+1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 3. Members
|
||||
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
||||
require.NoError(t, err)
|
||||
var members []models.Member
|
||||
var members []struct {
|
||||
Ko string `json:"ko"`
|
||||
En string `json:"en"`
|
||||
Degree string `json:"degree"`
|
||||
Affiliation *string `json:"affiliation"`
|
||||
Email *string `json:"email"`
|
||||
IsAdvisor bool `json:"is_advisor"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(mData, &members))
|
||||
for _, m := range members {
|
||||
for idx, m := range members {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
|
||||
`, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 4. Alumni
|
||||
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
||||
require.NoError(t, err)
|
||||
var alumni []models.Alumnus
|
||||
var alumni []struct {
|
||||
Ko string `json:"ko"`
|
||||
En string `json:"en"`
|
||||
Degree string `json:"degree"`
|
||||
GraduatedAt string `json:"graduatedAt"`
|
||||
Major *string `json:"major"`
|
||||
CurrentPosition *string `json:"currentPosition"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(alumniData, &alumni))
|
||||
for _, a := range alumni {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||
`, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 5. Publications
|
||||
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
||||
require.NoError(t, err)
|
||||
var pubs []models.Publication
|
||||
require.NoError(t, json.Unmarshal(pubData, &pubs))
|
||||
for _, p := range pubs {
|
||||
var publications []struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Authors *string `json:"authors"`
|
||||
Venue string `json:"venue"`
|
||||
Volume *string `json:"volume"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
KCI bool `json:"kci"`
|
||||
DOI *string `json:"doi"`
|
||||
IsHighlight bool `json:"is_highlight"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(pubData, &publications))
|
||||
for _, p := range publications {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -114,17 +146,26 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
||||
// 6. Patents
|
||||
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
||||
require.NoError(t, err)
|
||||
var patents []models.Patent
|
||||
var patents []struct {
|
||||
Title string `json:"title"`
|
||||
Inventors string `json:"inventors"`
|
||||
ApplicationNo string `json:"applicationNo"`
|
||||
ApplicationAt string `json:"applicationAt"`
|
||||
RegistrationNo *string `json:"registrationNo"`
|
||||
RegistrationAt *string `json:"registrationAt"`
|
||||
Country string `json:"country"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(patData, &patents))
|
||||
for _, pat := range patents {
|
||||
for _, p := range patents {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, pat.Title, pat.Inventors, pat.ApplicationNo, pat.ApplicationAt, pat.RegistrationNo, pat.RegistrationAt, pat.Country, pat.PublishedAt)
|
||||
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 7. Semesters & Courses
|
||||
// 7. Lectures
|
||||
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
||||
require.NoError(t, err)
|
||||
var semesters []struct {
|
||||
@@ -200,247 +241,327 @@ type APIResponse[T any] struct {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder {
|
||||
var reqBody []byte
|
||||
if body != nil {
|
||||
reqBody, _ = json.Marshal(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(reqBody))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/health")
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
w := doRequest(r, "GET", "/api/v1/health", nil, "")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[map[string]any]
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
}
|
||||
|
||||
func TestAdminAuthMiddleware(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// A. Configured with explicit valid token
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Unauthenticated request to /admin/verify should return 401
|
||||
wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code)
|
||||
|
||||
// 2. Guessable fallback token "admin123" must return 401
|
||||
wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123")
|
||||
assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code)
|
||||
|
||||
// 3. Wrong token request to /admin/verify should return 401
|
||||
wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token")
|
||||
assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code)
|
||||
|
||||
// 4. Valid token request to /admin/verify should return 200
|
||||
wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusOK, wValidAuth.Code)
|
||||
|
||||
// 5. Public GET routes remain accessible without auth
|
||||
wPublic := doRequest(r, "GET", "/api/v1/members", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wPublic.Code)
|
||||
|
||||
// B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts
|
||||
rEmptyToken := router.SetupRouter(db, "")
|
||||
|
||||
// 6. Any token on verify should return 401
|
||||
wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123")
|
||||
assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code)
|
||||
|
||||
wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code)
|
||||
|
||||
// 7. Mutating POST on empty router should return 401
|
||||
newProj := models.ResearchProject{
|
||||
Slug: "test-unauth",
|
||||
Title: "Unauthorized Title",
|
||||
Abstract: "Unauthorized abstract.",
|
||||
Keywords: []string{"unauth"},
|
||||
}
|
||||
wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123")
|
||||
assert.Equal(t, http.StatusUnauthorized, wMutate.Code)
|
||||
|
||||
// 8. Public GET routes still work on empty admin token router
|
||||
wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wPublicEmpty.Code)
|
||||
}
|
||||
|
||||
func TestResearchProjectsCRUD(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Create Project
|
||||
newProj := models.ResearchProject{
|
||||
Slug: "test-crud-proj",
|
||||
Title: "Test Project Title",
|
||||
Abstract: "Test project abstract for CRUD verification.",
|
||||
Keywords: []string{"test", "crud"},
|
||||
}
|
||||
wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||
|
||||
var createResp APIResponse[models.ResearchProject]
|
||||
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||
createdID := createResp.Data.ID
|
||||
assert.True(t, createdID > 0)
|
||||
|
||||
// 2. Update Project
|
||||
newProj.Title = "Updated Project Title"
|
||||
wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken)
|
||||
assert.Equal(t, http.StatusOK, wUpdate.Code)
|
||||
|
||||
// 3. Delete Project
|
||||
wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
||||
}
|
||||
|
||||
func TestResearchAreasDisplayOrderShift(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Fetch initial research areas
|
||||
wInit := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wInit.Code)
|
||||
var initResp APIResponse[[]models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
||||
initAreas := initResp.Data
|
||||
initCount := len(initAreas)
|
||||
require.NotEmpty(t, initAreas)
|
||||
|
||||
// Verify initial areas are contiguous 1..N
|
||||
for idx, a := range initAreas {
|
||||
assert.Equal(t, idx+1, a.DisplayOrder)
|
||||
}
|
||||
|
||||
// 2. Create at target display_order = 2 (should shift existing 2..N to 3..N+1 and normalize 1..N+1)
|
||||
newArea := models.ResearchArea{
|
||||
NameEn: "Quantum Internet Protocols",
|
||||
NameKr: ptrString("양자 인터넷 프로토콜"),
|
||||
DisplayOrder: 2,
|
||||
}
|
||||
|
||||
wCreate := doRequest(r, "POST", "/api/v1/research-areas", newArea, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||
var createResp APIResponse[models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||
createdID := createResp.Data.ID
|
||||
assert.Equal(t, 2, createResp.Data.DisplayOrder)
|
||||
|
||||
// Fetch updated list and assert display_order shifted correctly and gapless
|
||||
wAfterCreate := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wAfterCreate.Code)
|
||||
var afterCreateResp APIResponse[[]models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(wAfterCreate.Body.Bytes(), &afterCreateResp))
|
||||
afterCreateAreas := afterCreateResp.Data
|
||||
|
||||
assert.Equal(t, initCount+1, len(afterCreateAreas))
|
||||
assert.Equal(t, "Quantum Internet Protocols", afterCreateAreas[1].NameEn)
|
||||
for idx, a := range afterCreateAreas {
|
||||
assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have display_order %d", idx, idx+1)
|
||||
}
|
||||
|
||||
// 3. Update display_order of the created item from position 2 to position 5
|
||||
updateArea := models.ResearchArea{
|
||||
NameEn: "Quantum Internet Protocols Updated",
|
||||
DisplayOrder: 5,
|
||||
}
|
||||
wUpdate := doRequest(r, "PUT", "/api/v1/research-areas/"+strconvItoa(createdID), updateArea, testAdminToken)
|
||||
assert.Equal(t, http.StatusOK, wUpdate.Code)
|
||||
|
||||
wAfterUpdate := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
|
||||
var afterUpdateResp APIResponse[[]models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(wAfterUpdate.Body.Bytes(), &afterUpdateResp))
|
||||
afterUpdateAreas := afterUpdateResp.Data
|
||||
|
||||
assert.Equal(t, initCount+1, len(afterUpdateAreas))
|
||||
assert.Equal(t, "Quantum Internet Protocols Updated", afterUpdateAreas[4].NameEn)
|
||||
assert.Equal(t, 5, afterUpdateAreas[4].DisplayOrder)
|
||||
for idx, a := range afterUpdateAreas {
|
||||
assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have contiguous display_order %d after update", idx, idx+1)
|
||||
}
|
||||
|
||||
// 4. Delete the item and verify gapless 1..N normalization back to initCount
|
||||
wDel := doRequest(r, "DELETE", "/api/v1/research-areas/"+strconvItoa(createdID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDel.Code)
|
||||
|
||||
wAfterDel := doRequest(r, "GET", "/api/v1/research-areas", nil, "")
|
||||
var afterDelResp APIResponse[[]models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(wAfterDel.Body.Bytes(), &afterDelResp))
|
||||
afterDelAreas := afterDelResp.Data
|
||||
|
||||
assert.Equal(t, initCount, len(afterDelAreas))
|
||||
for idx, a := range afterDelAreas {
|
||||
assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have contiguous display_order %d after delete", idx, idx+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembersAndAlumniCRUD(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Create Member with Invalid Degree -> 400
|
||||
badMember := models.Member{
|
||||
NameKo: "홍길동",
|
||||
NameEn: "Gildong Hong",
|
||||
Degree: "InvalidDegree",
|
||||
}
|
||||
wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken)
|
||||
assert.Equal(t, http.StatusBadRequest, wBad.Code)
|
||||
|
||||
// 2. Create Valid Member -> 201
|
||||
validMember := models.Member{
|
||||
NameKo: "홍길동",
|
||||
NameEn: "Gildong Hong",
|
||||
Degree: "PhDStudent",
|
||||
}
|
||||
wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||
|
||||
var createResp APIResponse[models.Member]
|
||||
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||
mID := createResp.Data.ID
|
||||
|
||||
// 3. Delete Member -> 204
|
||||
wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
||||
|
||||
// 4. Create Alumnus -> 201
|
||||
validAlumnus := models.Alumnus{
|
||||
NameKo: "김졸업",
|
||||
NameEn: "Grad Kim",
|
||||
Degree: "MS",
|
||||
GraduatedAt: "2024-02",
|
||||
}
|
||||
wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreateAl.Code)
|
||||
|
||||
var alResp APIResponse[models.Alumnus]
|
||||
require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp))
|
||||
alID := alResp.Data.ID
|
||||
|
||||
// 5. Delete Alumnus -> 204
|
||||
wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelAl.Code)
|
||||
}
|
||||
|
||||
func TestCascadeDeletes(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Create a semester
|
||||
newSem := models.Semester{Year: 2030, Term: "Spring"}
|
||||
wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wSem.Code)
|
||||
var semResp APIResponse[models.Semester]
|
||||
require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp))
|
||||
semID := semResp.Data.ID
|
||||
|
||||
// 2. Create courses under this semester
|
||||
course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"}
|
||||
course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"}
|
||||
wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCourse1.Code)
|
||||
wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCourse2.Code)
|
||||
|
||||
// Verify courses exist in DB
|
||||
var courseCount int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp.Data["status"])
|
||||
assert.Equal(t, "anl-backend-api", resp.Data["service"])
|
||||
assert.Equal(t, 2, courseCount)
|
||||
|
||||
// 3. Delete semester
|
||||
wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelSem.Code)
|
||||
|
||||
// 4. Verify cascade delete removed courses
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
|
||||
}
|
||||
|
||||
func TestResearchProjects(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
func TestStaticServingAndRedirects(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/research-projects")
|
||||
// 1. /intro 308 permanent redirect to /
|
||||
wIntro := doRequest(r, "GET", "/intro", nil, "")
|
||||
assert.Equal(t, http.StatusPermanentRedirect, wIntro.Code)
|
||||
assert.Equal(t, "/", wIntro.Header().Get("Location"))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.ResearchProject]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 3)
|
||||
assert.Equal(t, "iot-standards", resp.Data[0].Slug)
|
||||
assert.Equal(t, "AIoT & AI-RAN", resp.Data[0].Title)
|
||||
// 2. Unknown API route returns 404 JSON (not HTML)
|
||||
wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wAPI404.Code)
|
||||
var apiErr map[string]any
|
||||
require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr))
|
||||
assert.Contains(t, apiErr, "error")
|
||||
|
||||
// 3. Create mock web directory to verify static HTML serving
|
||||
webDir := "./web"
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "test-page"), 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html>Home</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("<html>Test Page</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("<html>404 Not Found</html>"), 0644))
|
||||
defer os.RemoveAll(webDir)
|
||||
|
||||
// Test root URL /
|
||||
wRoot := doRequest(r, "GET", "/", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wRoot.Code)
|
||||
assert.Contains(t, wRoot.Body.String(), "<html>Home</html>")
|
||||
|
||||
// Test sub-route /test-page
|
||||
wTestPage := doRequest(r, "GET", "/test-page", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wTestPage.Code)
|
||||
assert.Contains(t, wTestPage.Body.String(), "<html>Test Page</html>")
|
||||
|
||||
// Test unknown page -> 404 HTML
|
||||
wUnknown := doRequest(r, "GET", "/completely-unknown-route", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wUnknown.Code)
|
||||
assert.Contains(t, wUnknown.Body.String(), "<html>404 Not Found</html>")
|
||||
}
|
||||
|
||||
func TestResearchAreas(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/research-areas")
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 14)
|
||||
assert.Equal(t, 1, resp.Data[0].DisplayOrder)
|
||||
assert.Equal(t, "Quick UDP Internet Connection (QUIC)", resp.Data[0].NameEn)
|
||||
}
|
||||
|
||||
func TestStatsSummary(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/stats/summary")
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[models.StatsSummary]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, 68, resp.Data.IntlPublications)
|
||||
assert.Equal(t, 44, resp.Data.StandardizationDocs)
|
||||
assert.Equal(t, 75, resp.Data.Patents)
|
||||
}
|
||||
|
||||
func TestMembersAndAlumni(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
// Members
|
||||
wMem := executeRequest(r, "GET", "/api/v1/members")
|
||||
assert.Equal(t, http.StatusOK, wMem.Code)
|
||||
var respMem APIResponse[[]models.Member]
|
||||
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &respMem))
|
||||
assert.Len(t, respMem.Data, 16)
|
||||
assert.True(t, respMem.Data[0].IsAdvisor)
|
||||
assert.Equal(t, "Seok-Joo Koh", respMem.Data[0].NameEn)
|
||||
|
||||
// Alumni
|
||||
wAlumni := executeRequest(r, "GET", "/api/v1/alumni")
|
||||
assert.Equal(t, http.StatusOK, wAlumni.Code)
|
||||
var respAlumni APIResponse[[]models.Alumnus]
|
||||
require.NoError(t, json.Unmarshal(wAlumni.Body.Bytes(), &respAlumni))
|
||||
assert.Len(t, respAlumni.Data, 60)
|
||||
assert.GreaterOrEqual(t, respAlumni.Data[0].GraduatedAt, respAlumni.Data[1].GraduatedAt)
|
||||
}
|
||||
|
||||
func TestPublicationsAndHighlights(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
// Full Publications
|
||||
wAll := executeRequest(r, "GET", "/api/v1/publications")
|
||||
assert.Equal(t, http.StatusOK, wAll.Code)
|
||||
var respAll APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wAll.Body.Bytes(), &respAll))
|
||||
assert.Len(t, respAll.Data, 148)
|
||||
|
||||
// Category filter: intl
|
||||
wIntl := executeRequest(r, "GET", "/api/v1/publications?category=intl-journal-conf")
|
||||
assert.Equal(t, http.StatusOK, wIntl.Code)
|
||||
var respIntl APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wIntl.Body.Bytes(), &respIntl))
|
||||
assert.Len(t, respIntl.Data, 68)
|
||||
|
||||
// Category filter: domestic
|
||||
wDom := executeRequest(r, "GET", "/api/v1/publications?category=domestic-journal-conf")
|
||||
assert.Equal(t, http.StatusOK, wDom.Code)
|
||||
var respDom APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wDom.Body.Bytes(), &respDom))
|
||||
assert.Len(t, respDom.Data, 80)
|
||||
|
||||
// Invalid category
|
||||
wInvalid := executeRequest(r, "GET", "/api/v1/publications?category=unknown")
|
||||
assert.Equal(t, http.StatusBadRequest, wInvalid.Code)
|
||||
|
||||
// Highlights (asserts exactly the 5 known representative titles)
|
||||
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
|
||||
assert.Equal(t, http.StatusOK, wHL.Code)
|
||||
var respHL APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &respHL))
|
||||
assert.Len(t, respHL.Data, 5)
|
||||
|
||||
expectedTitles := map[string]bool{
|
||||
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G": true,
|
||||
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G": true,
|
||||
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks": true,
|
||||
"Use of QUIC for CoAP Transport in IoT Networks": true,
|
||||
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)": true,
|
||||
}
|
||||
for _, hl := range respHL.Data {
|
||||
assert.True(t, hl.IsHighlight)
|
||||
assert.True(t, expectedTitles[hl.Title], "Unexpected highlight title: %s", hl.Title)
|
||||
}
|
||||
|
||||
// Patents
|
||||
wPat := executeRequest(r, "GET", "/api/v1/patents")
|
||||
assert.Equal(t, http.StatusOK, wPat.Code)
|
||||
var respPat APIResponse[[]models.Patent]
|
||||
require.NoError(t, json.Unmarshal(wPat.Body.Bytes(), &respPat))
|
||||
assert.Len(t, respPat.Data, 75)
|
||||
}
|
||||
|
||||
func TestSemestersAndCourses(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
w := executeRequest(r, "GET", "/api/v1/semesters")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.SemesterWithCourses]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 45)
|
||||
|
||||
// Check semester ordering (Year DESC, Fall before Spring within same year)
|
||||
first := resp.Data[0]
|
||||
assert.Equal(t, 2026, first.Year)
|
||||
assert.Equal(t, "Spring", first.Term)
|
||||
assert.NotEmpty(t, first.Courses)
|
||||
|
||||
// In 2025: Fall must come before Spring
|
||||
var s2025Fall, s2025Spring int
|
||||
for idx, s := range resp.Data {
|
||||
if s.Year == 2025 && s.Term == "Fall" {
|
||||
s2025Fall = idx
|
||||
}
|
||||
if s.Year == 2025 && s.Term == "Spring" {
|
||||
s2025Spring = idx
|
||||
}
|
||||
}
|
||||
assert.Less(t, s2025Fall, s2025Spring, "2025 Fall should precede 2025 Spring in chronological descending order")
|
||||
}
|
||||
|
||||
func TestStandardsBodiesAndHierarchy(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
w := executeRequest(r, "GET", "/api/v1/standards-bodies")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.StandardsBodyWithProjects]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 6)
|
||||
|
||||
// First body: IEC TC100
|
||||
iec := resp.Data[0]
|
||||
assert.Equal(t, "IEC TC100", iec.Org)
|
||||
require.NotEmpty(t, iec.Projects)
|
||||
|
||||
// Verify project ordering within IEC TC100 (deterministic order: WG12 before WG11(Convenor))
|
||||
assert.Equal(t, "WG12", iec.Projects[0].WG)
|
||||
assert.Equal(t, "WG11(Convenor)", iec.Projects[1].WG)
|
||||
assert.NotEmpty(t, iec.Projects[0].Documents)
|
||||
}
|
||||
|
||||
func TestJSONKeyCasingSnakeCase(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
// Test Members endpoint raw JSON map
|
||||
wMem := executeRequest(r, "GET", "/api/v1/members")
|
||||
assert.Equal(t, http.StatusOK, wMem.Code)
|
||||
var rawMem map[string]any
|
||||
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &rawMem))
|
||||
dataList, ok := rawMem["data"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, dataList)
|
||||
firstMember, ok := dataList[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
// Verify snake_case keys exist and camelCase keys do not
|
||||
assert.Contains(t, firstMember, "name_ko")
|
||||
assert.Contains(t, firstMember, "name_en")
|
||||
assert.Contains(t, firstMember, "is_advisor")
|
||||
assert.Contains(t, firstMember, "display_order")
|
||||
assert.NotContains(t, firstMember, "nameKo")
|
||||
assert.NotContains(t, firstMember, "isAdvisor")
|
||||
|
||||
// Test Highlights endpoint raw JSON map
|
||||
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
|
||||
assert.Equal(t, http.StatusOK, wHL.Code)
|
||||
var rawHL map[string]any
|
||||
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &rawHL))
|
||||
hlList, ok := rawHL["data"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, hlList)
|
||||
firstHL, ok := hlList[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Contains(t, firstHL, "is_highlight")
|
||||
assert.Contains(t, firstHL, "published_at")
|
||||
assert.NotContains(t, firstHL, "isHighlight")
|
||||
assert.NotContains(t, firstHL, "publishedAt")
|
||||
func strconvItoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
+12
-6
@@ -1,12 +1,16 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
anl-backend:
|
||||
anl-app:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: anl-backend-api
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-}
|
||||
container_name: anl-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8080
|
||||
@@ -14,13 +18,15 @@ services:
|
||||
- GIN_MODE=release
|
||||
- CORS_ALLOW_ORIGINS=*
|
||||
- AUTO_MIGRATE=true
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set -- see .env.example}
|
||||
volumes:
|
||||
- anl-db-data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"]
|
||||
interval: 30s
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
anl-db-data:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{action}
|
||||
<ThemeToggle />
|
||||
</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,155 @@
|
||||
"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">
|
||||
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-ink/60 mt-1">Lab Data Management</p>
|
||||
</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,828 @@
|
||||
"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.length})</option>
|
||||
<option value="intl-journal-conf">
|
||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
|
||||
</option>
|
||||
<option value="domestic-journal-conf">
|
||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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.length})</option>
|
||||
{pubYears.map((yr) => {
|
||||
const count = publications.filter((p) => (p.published_at || "").includes(yr)).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,17 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getSemesters } from "../../lib/api";
|
||||
import type { Semester } from "../../content/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Lectures",
|
||||
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
|
||||
};
|
||||
export default function LecturesPage() {
|
||||
const [semesters, setSemesters] = useState<Semester[]>([]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LecturesPage() {
|
||||
const semesters = (await getSemesters()) ?? [];
|
||||
useEffect(() => {
|
||||
getSemesters().then((s) => {
|
||||
if (s) setSemesters(s);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const recentSemesters = semesters.slice(0, 3);
|
||||
const olderSemesters = semesters.slice(3);
|
||||
|
||||
@@ -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,9 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getMembers, getAlumni } from "../../lib/api";
|
||||
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Members",
|
||||
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import type { Member, Alumnus } from "../../content/types";
|
||||
|
||||
const DEGREE_LABEL: Record<string, string> = {
|
||||
Professor: "Professor",
|
||||
@@ -20,9 +15,18 @@ const DEGREE_LABEL: Record<string, string> = {
|
||||
MS: "M. S.",
|
||||
};
|
||||
|
||||
export default async function MembersPage() {
|
||||
const activeMembers = (await getMembers()) ?? [];
|
||||
const alumni = (await getAlumni()) ?? [];
|
||||
export default function MembersPage() {
|
||||
const [activeMembers, setActiveMembers] = useState<Member[]>([]);
|
||||
const [alumni, setAlumni] = useState<Alumnus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getMembers().then((m) => {
|
||||
if (m) setActiveMembers(m);
|
||||
});
|
||||
getAlumni().then((a) => {
|
||||
if (a) setAlumni(a);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const phdStudents = activeMembers.filter(
|
||||
(m) =>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import HeroComposition from "./_components/HeroComposition";
|
||||
import { ProjectPageView } from "./_components/ProjectPageView";
|
||||
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import type { ResearchProject } from "../content/types";
|
||||
|
||||
const INTERNATIONAL_SDOS = [
|
||||
"IEC TC100: Multimedia Systems and Equipment",
|
||||
@@ -14,15 +15,26 @@ const INTERNATIONAL_SDOS = [
|
||||
"ISO/IEC JTC1/SC6: Reliable Multicasting",
|
||||
];
|
||||
|
||||
export default async function HomePage() {
|
||||
const stats = (await getStatsSummary()) ?? {
|
||||
export default function HomePage() {
|
||||
const [stats, setStats] = useState({
|
||||
intlPubsCount: 0,
|
||||
patentCount: 0,
|
||||
stdDocsCount: 0,
|
||||
};
|
||||
});
|
||||
const [researchAreas, setResearchAreas] = useState<string[]>([]);
|
||||
const [researchProjects, setResearchProjects] = useState<ResearchProject[]>([]);
|
||||
|
||||
const researchAreas = (await getResearchAreaNames()) ?? [];
|
||||
const researchProjects = (await getResearchProjects()) ?? [];
|
||||
useEffect(() => {
|
||||
getStatsSummary().then((s) => {
|
||||
if (s) setStats(s);
|
||||
});
|
||||
getResearchAreaNames().then((areas) => {
|
||||
if (areas) setResearchAreas(areas);
|
||||
});
|
||||
getResearchProjects().then((projs) => {
|
||||
if (projs) setResearchProjects(projs);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoryNav } from "../../_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../../../lib/api";
|
||||
import { PUB_CATEGORIES } from "../../../../content/categories";
|
||||
import type { PubCategory, Publication, Patent } from "../../../../content/types";
|
||||
|
||||
export function PublicationCategoryClient({ category }: { category: string }) {
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||
if (!currentCat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = category as PubCategory;
|
||||
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [patents, setPatents] = useState<Patent[]>([]);
|
||||
|
||||
const [selectedYear, setSelectedYear] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
getPublications().then((p) => {
|
||||
if (p) setPublications(p);
|
||||
});
|
||||
getPatents().then((pat) => {
|
||||
if (pat) setPatents(pat);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
|
||||
patent: patents.length,
|
||||
};
|
||||
|
||||
let allRecords: any[] = [];
|
||||
if (slug === "patent") {
|
||||
allRecords = patents.map((p) => ({
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
appNo: p.applicationNo,
|
||||
appDate: p.applicationAt,
|
||||
regNo: p.registrationNo,
|
||||
regDate: p.registrationAt,
|
||||
country: p.country,
|
||||
publishedAt: p.publishedAt,
|
||||
isPatent: true,
|
||||
}));
|
||||
} else {
|
||||
allRecords = publications
|
||||
.filter((p) => p.category === slug)
|
||||
.map((p) => ({
|
||||
title: p.title,
|
||||
venue: p.venue,
|
||||
volume: p.volume,
|
||||
publishedAt: p.publishedAt,
|
||||
kci: p.kci,
|
||||
isPatent: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract unique sorted years
|
||||
const availableYears = Array.from(
|
||||
new Set(
|
||||
allRecords
|
||||
.map((r) => (r.publishedAt || "").match(/\b(19\d\d|20\d\d)\b/)?.[1])
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const records =
|
||||
selectedYear === "all"
|
||||
? allRecords
|
||||
: allRecords.filter((r) => (r.publishedAt || "").includes(selectedYear));
|
||||
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section className="space-y-1">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Category Nav Cards */}
|
||||
<section className="pt-6 md:pt-8 border-t border-line">
|
||||
<CategoryNav currentCategory={slug} counts={counts} />
|
||||
</section>
|
||||
|
||||
{/* Record List Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
|
||||
>
|
||||
<option value="all">All Years ({allRecords.length})</option>
|
||||
{availableYears.map((yr) => {
|
||||
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({yrCount})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{selectedYear !== "all" && (
|
||||
<button
|
||||
onClick={() => setSelectedYear("all")}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
Showing {records.length} of {allRecords.length} Records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||
<p className="font-serif text-lg text-ink">
|
||||
No records found in this category.
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Records will be updated periodically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{records.map((rec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||
{rec.title}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={rec.publishedAt}
|
||||
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
|
||||
>
|
||||
{rec.publishedAt}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{rec.isPatent ? (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink">Inventors: {rec.inventors}</p>
|
||||
<p>
|
||||
App No: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||
</p>
|
||||
{rec.regNo && (
|
||||
<p className="text-cobalt-dark font-bold">
|
||||
Reg No: {rec.regNo} ({rec.regDate})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink font-serif italic">{rec.venue}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{rec.volume}</span>
|
||||
{rec.kci && (
|
||||
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||
KCI Indexed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoryNav } from "../_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../../lib/api";
|
||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||
import { PubCategory } from "../../../content/types";
|
||||
import { PublicationCategoryClient } from "./_components/PublicationCategoryClient";
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -12,6 +8,10 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return PUB_CATEGORIES.map((c) => ({ category: c.slug }));
|
||||
}
|
||||
|
||||
export function generateMetadata({ params }: PageProps): Metadata {
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === params.category);
|
||||
if (!currentCat) {
|
||||
@@ -23,138 +23,6 @@ export function generateMetadata({ params }: PageProps): Metadata {
|
||||
};
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationCategoryPage({ params }: PageProps) {
|
||||
const { category } = params;
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||
|
||||
if (!currentCat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = category as PubCategory;
|
||||
|
||||
const publications = (await getPublications()) ?? [];
|
||||
const patents = (await getPatents()) ?? [];
|
||||
|
||||
const counts = {
|
||||
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
|
||||
patent: patents.length,
|
||||
};
|
||||
|
||||
let records: any[] = [];
|
||||
if (slug === "patent") {
|
||||
records = patents.map((p) => ({
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
appNo: p.applicationNo,
|
||||
appDate: p.applicationAt,
|
||||
regNo: p.registrationNo,
|
||||
regDate: p.registrationAt,
|
||||
country: p.country,
|
||||
publishedAt: p.publishedAt,
|
||||
isPatent: true,
|
||||
}));
|
||||
} else {
|
||||
records = publications
|
||||
.filter((p) => p.category === slug)
|
||||
.map((p) => ({
|
||||
title: p.title,
|
||||
venue: p.venue,
|
||||
volume: p.volume,
|
||||
publishedAt: p.publishedAt,
|
||||
kci: p.kci,
|
||||
isPatent: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section className="space-y-1">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Category Nav Cards */}
|
||||
<section className="pt-6 md:pt-8 border-t border-line">
|
||||
<CategoryNav currentCategory={slug} counts={counts} />
|
||||
</section>
|
||||
|
||||
{/* Record List Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="flex justify-between items-center border-b border-line pb-3">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
{records.length} Records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||
<p className="font-serif text-lg text-ink">
|
||||
No records found in this category.
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Records will be updated periodically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{records.map((rec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||
{rec.title}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={rec.publishedAt}
|
||||
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
|
||||
>
|
||||
{rec.publishedAt}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{rec.isPatent ? (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink">Inventors: {rec.inventors}</p>
|
||||
<p>
|
||||
App No: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||
</p>
|
||||
{rec.regNo && (
|
||||
<p className="text-cobalt-dark font-bold">
|
||||
Reg No: {rec.regNo} ({rec.regDate})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink font-serif italic">{rec.venue}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{rec.volume}</span>
|
||||
{rec.kci && (
|
||||
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||
KCI Indexed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
export default function PublicationCategoryPage({ params }: PageProps) {
|
||||
return <PublicationCategoryClient category={params.category} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Publications",
|
||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||
};
|
||||
|
||||
export default function PublicationsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CategoryNav } from "./_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../lib/api";
|
||||
import type { Publication, Patent } from "../../content/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Publications",
|
||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||
};
|
||||
export default function PublicationsIndexPage() {
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [patents, setPatents] = useState<Patent[]>([]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationsIndexPage() {
|
||||
const publications = (await getPublications()) ?? [];
|
||||
const patents = (await getPatents()) ?? [];
|
||||
useEffect(() => {
|
||||
getPublications().then((p) => {
|
||||
if (p) setPublications(p);
|
||||
});
|
||||
getPatents().then((pat) => {
|
||||
if (pat) setPatents(pat);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Standardization",
|
||||
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
||||
};
|
||||
|
||||
export default function StandardizationLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getStandardsBodies } from "../../lib/api";
|
||||
import type { StandardsBody } from "../../content/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Standardization",
|
||||
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
||||
};
|
||||
export default function StandardizationPage() {
|
||||
const [standardsBodies, setStandardsBodies] = useState<StandardsBody[]>([]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StandardizationPage() {
|
||||
const standardsBodies = (await getStandardsBodies()) ?? [];
|
||||
useEffect(() => {
|
||||
getStandardsBodies().then((sb) => {
|
||||
if (sb) setStandardsBodies(sb);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Marquee from "@/components/Marquee";
|
||||
|
||||
// CUSTOMIZATION HOOK: replace address / email placeholders & colophon below.
|
||||
@@ -11,6 +14,11 @@ const links = [
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname && pathname.startsWith("/console")) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<footer className="mt-0 border-t border-line bg-paper text-ink lg:mt-8">
|
||||
{/* Marquee band — colophon ticker */}
|
||||
|
||||
@@ -106,6 +106,10 @@ export default function Header() {
|
||||
: pathname === href || pathname.startsWith(`${href}/`)
|
||||
: false;
|
||||
|
||||
if (pathname && pathname.startsWith("/console")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
data-smart-sticky
|
||||
@@ -133,7 +137,7 @@ export default function Header() {
|
||||
className="group flex items-center gap-3 min-w-0 desktop:shrink-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl overflow-hidden shadow-sm ring-1 ring-ink/10 group-hover:ring-cobalt/40 group-hover:shadow-md group-hover:scale-105 transition-all duration-300 shrink-0">
|
||||
<div className="relative flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl overflow-hidden shadow-sm border border-line group-hover:border-cobalt/40 group-hover:shadow-md group-hover:scale-105 transition-all duration-300 shrink-0">
|
||||
<svg viewBox="0 0 32 32" className="w-full h-full" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="headerLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
|
||||
@@ -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,
|
||||
} from "../content/types";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8080/api/v1";
|
||||
export function normalizeApiUrl(rawUrl?: string): string {
|
||||
let url = (rawUrl || "").trim().replace(/\/+$/, "");
|
||||
if (!url || url === "/" || url === "/api" || url === "/api/v1") {
|
||||
return "/api/v1";
|
||||
}
|
||||
if (url.endsWith("/api/v1")) {
|
||||
return url;
|
||||
}
|
||||
if (url.endsWith("/api")) {
|
||||
return `${url}/v1`;
|
||||
}
|
||||
return `${url}/api/v1`;
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
// Browser / CSR context:
|
||||
if (process.env.NEXT_PUBLIC_API_BASE_URL) {
|
||||
return normalizeApiUrl(process.env.NEXT_PUBLIC_API_BASE_URL);
|
||||
}
|
||||
return "/api/v1";
|
||||
}
|
||||
// Server / build-time context: defensive fallback during static prerendering
|
||||
const raw =
|
||||
process.env.API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
"http://localhost:8080/api/v1";
|
||||
return normalizeApiUrl(raw);
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
|
||||
interface APIResponse<T> {
|
||||
@@ -142,7 +171,8 @@ async function apiGet<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
reactStrictMode: true,
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/intro",
|
||||
destination: "/",
|
||||
permanent: true, // 308 permanent redirect (H-11 / IA-02)
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -150,7 +150,7 @@ def verify():
|
||||
try:
|
||||
def fetch_json(endpoint):
|
||||
req = urllib.request.Request(f"{API_BASE_URL}{endpoint}", headers={"User-Agent": "DataVerifier"})
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return data.get("data", [])
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
|
||||
E2E Test Suite Runner for ANL Homepage Development (v3.0 Unified Go Backend Architecture)
|
||||
Verifies:
|
||||
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
|
||||
1. ESLint (npm run lint)
|
||||
2. Next.js Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
|
||||
2. Next.js Static Export Build (npm run build) & Build Completeness Gate (AC-17)
|
||||
3. TypeScript type check (npx tsc --noEmit)
|
||||
4. Live Server Route Verification (Live HTTP status 200 & content)
|
||||
4. Live Server Route Verification (Live HTTP status 200 & content from unified Go binary)
|
||||
5. Data count losslessness & fallback dataset integrity (verify_counts.py)
|
||||
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
|
||||
7. Live DOM & HTML Content Assertions (Unittest)
|
||||
@@ -21,24 +21,25 @@ import time
|
||||
import subprocess
|
||||
import re
|
||||
import urllib.request
|
||||
import shutil
|
||||
import socket
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page")
|
||||
|
||||
EXPECTED_DYNAMIC_ROUTES = [
|
||||
EXPECTED_DYNAMIC_ROUTES = []
|
||||
|
||||
EXPECTED_STATIC_ROUTES = [
|
||||
"/",
|
||||
"/_not-found",
|
||||
"/icon.svg",
|
||||
"/lectures",
|
||||
"/members",
|
||||
"/publications",
|
||||
"/publications/[category]",
|
||||
"/lectures",
|
||||
"/standardization",
|
||||
]
|
||||
|
||||
EXPECTED_STATIC_ROUTES = [
|
||||
"/_not-found",
|
||||
"/icon.svg",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
"/standardization",
|
||||
]
|
||||
|
||||
LIVE_ROUTES = [
|
||||
@@ -80,13 +81,13 @@ def test_lint():
|
||||
return True
|
||||
|
||||
def test_build():
|
||||
print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
|
||||
print("\n--- Gate 2: Next.js Static Export & Build Completeness Gate (AC-17) ---")
|
||||
res = run_cmd(["npm", "run", "build"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
|
||||
# Parse exact Next.js route table output lines (e.g. "┌ ƒ /", "├ ○ /_not-found", "├ ƒ /publications", "├ ƒ /publications/[category]")
|
||||
# Parse exact Next.js route table output lines
|
||||
route_table_map = {}
|
||||
for line in res.stdout.splitlines():
|
||||
m = re.match(r'^[┌├└]\s+([ƒ○●])\s+(\S+)', line.strip())
|
||||
@@ -96,28 +97,55 @@ def test_build():
|
||||
|
||||
print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}")
|
||||
|
||||
# Check exact match for each dynamic route
|
||||
for d_route in EXPECTED_DYNAMIC_ROUTES:
|
||||
mode = route_table_map.get(d_route)
|
||||
if mode != "ƒ":
|
||||
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
|
||||
return False
|
||||
# Filter public routes vs admin console routes
|
||||
public_route_table = {k: v for k, v in route_table_map.items() if not k.startswith("/console")}
|
||||
console_routes = {k: v for k, v in route_table_map.items() if k.startswith("/console")}
|
||||
|
||||
print(f"Public routes ({len(public_route_table)}): {public_route_table}")
|
||||
if console_routes:
|
||||
print(f"Admin Console routes ({len(console_routes)}): {console_routes}")
|
||||
|
||||
# Check exact match for each static route
|
||||
# Check exact match for each static public route
|
||||
for s_route in EXPECTED_STATIC_ROUTES:
|
||||
mode = route_table_map.get(s_route)
|
||||
mode = public_route_table.get(s_route)
|
||||
if mode not in ("○", "●"):
|
||||
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'")
|
||||
return False
|
||||
|
||||
# Check that no other unexpected routes exist in table
|
||||
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
|
||||
found_routes = set(route_table_map.keys())
|
||||
if all_expected != found_routes:
|
||||
print(f"❌ Gate 2 Violation: Route set mismatch. Missing: {all_expected - found_routes}, Extra: {found_routes - all_expected}")
|
||||
# Check that public route set matches exactly
|
||||
all_expected = set(EXPECTED_STATIC_ROUTES)
|
||||
found_public = set(public_route_table.keys())
|
||||
if all_expected != found_public:
|
||||
print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
|
||||
# Build Completeness Check: verify exported files on disk under out/
|
||||
out_dir = os.path.join(REFER_DIR, "out")
|
||||
required_files = [
|
||||
"index.html",
|
||||
os.path.join("members", "index.html"),
|
||||
os.path.join("publications", "index.html"),
|
||||
os.path.join("publications", "intl-journal-conf", "index.html"),
|
||||
os.path.join("publications", "domestic-journal-conf", "index.html"),
|
||||
os.path.join("publications", "patent", "index.html"),
|
||||
os.path.join("lectures", "index.html"),
|
||||
os.path.join("standardization", "index.html"),
|
||||
"robots.txt",
|
||||
"sitemap.xml",
|
||||
"icon.svg",
|
||||
]
|
||||
for rf in required_files:
|
||||
fpath = os.path.join(out_dir, rf)
|
||||
if not os.path.exists(fpath) or os.path.getsize(fpath) == 0:
|
||||
print(f"❌ Gate 2 Completeness Violation: Missing or empty exported file '{rf}' under out/")
|
||||
return False
|
||||
|
||||
# Check 404 file existence
|
||||
if not (os.path.exists(os.path.join(out_dir, "404.html")) or os.path.exists(os.path.join(out_dir, "404", "index.html"))):
|
||||
print("❌ Gate 2 Completeness Violation: Missing 404 static error page under out/")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Static export route allocation & build-completeness verified (10 static routes, all files present in out/)")
|
||||
return True
|
||||
|
||||
def test_tsc():
|
||||
@@ -129,30 +157,39 @@ def test_tsc():
|
||||
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
|
||||
return True
|
||||
|
||||
import socket
|
||||
|
||||
def get_free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
def start_next_server(port=3000):
|
||||
print(f"\nStarting live Next.js server on port {port}...")
|
||||
server_env = os.environ.copy()
|
||||
server_env["PORT"] = str(port)
|
||||
def start_unified_go_server(port=8080):
|
||||
backend_dir = os.path.join(ROOT_DIR, "backend")
|
||||
out_dir = os.path.join(REFER_DIR, "out")
|
||||
web_dir = os.path.join(backend_dir, "web")
|
||||
|
||||
# Sync out/ into backend/web for static serving
|
||||
shutil.rmtree(web_dir, ignore_errors=True)
|
||||
if os.path.exists(out_dir):
|
||||
shutil.copytree(out_dir, web_dir)
|
||||
|
||||
print(f"\nStarting unified Go backend server on port {port}...")
|
||||
b_env = os.environ.copy()
|
||||
b_env["PORT"] = str(port)
|
||||
b_env["GIN_MODE"] = "release"
|
||||
b_env["ADMIN_TOKEN"] = "dev_admin_secret_token_12345"
|
||||
proc = subprocess.Popen(
|
||||
["npm", "run", "start"],
|
||||
cwd=REFER_DIR,
|
||||
["go", "run", "./cmd/api", f"--port={port}", f"--db={os.path.join(backend_dir, 'anl.db')}"],
|
||||
cwd=backend_dir,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=server_env,
|
||||
env=b_env,
|
||||
)
|
||||
|
||||
|
||||
ready = False
|
||||
for _ in range(30):
|
||||
for _ in range(40):
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://localhost:{port}/", timeout=1) as resp:
|
||||
with urllib.request.urlopen(f"http://localhost:{port}/api/v1/health", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
ready = True
|
||||
break
|
||||
@@ -161,12 +198,12 @@ def start_next_server(port=3000):
|
||||
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
raise RuntimeError(f"Next.js server failed to become ready on port {port}")
|
||||
|
||||
print(f"Live Next.js server is ready at http://localhost:{port}")
|
||||
raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}")
|
||||
|
||||
print(f"✅ Unified Go server is ready at http://localhost:{port}")
|
||||
return proc
|
||||
|
||||
def fetch_live_routes(base_url="http://localhost:3000"):
|
||||
def fetch_live_routes(base_url="http://localhost:8080"):
|
||||
bodies = {}
|
||||
for route in LIVE_ROUTES:
|
||||
url = f"{base_url}{route}"
|
||||
@@ -236,7 +273,7 @@ def test_theme_tokens():
|
||||
print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
|
||||
return True
|
||||
|
||||
def test_unittest_suite(port=3000):
|
||||
def test_unittest_suite(port=8080):
|
||||
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
|
||||
test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py")
|
||||
res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": f"http://localhost:{port}"})
|
||||
@@ -249,7 +286,6 @@ def test_unittest_suite(port=3000):
|
||||
def test_layout_architecture(bodies):
|
||||
print("\n--- Gate 8: Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17) ---")
|
||||
|
||||
# 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
|
||||
main_hashes = {}
|
||||
for route, html in bodies.items():
|
||||
if "_not-found" in route or "404" in route:
|
||||
@@ -262,30 +298,28 @@ def test_layout_architecture(bodies):
|
||||
|
||||
main_inner = main_match.group(1)
|
||||
|
||||
# C1: Verify child wrapper inside <main> has container-content
|
||||
if "container-content" not in main_inner:
|
||||
print(f"❌ container-content missing inside <main> in {route}")
|
||||
return False
|
||||
|
||||
# AC-41: Check for duplicate <main> normalized content hashes
|
||||
norm_content = re.sub(r'\s+', '', main_inner)
|
||||
if norm_content in main_hashes:
|
||||
print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
|
||||
return False
|
||||
main_hashes[norm_content] = route
|
||||
|
||||
# 2) Check source for forbidden escaped arbitrary classes (AC-26)
|
||||
forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
|
||||
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for fname in files:
|
||||
if fname.endswith((".ts", ".tsx")):
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not fname.endswith((".tsx", ".ts", ".css")):
|
||||
continue
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for term in forbidden_terms:
|
||||
if term in content:
|
||||
print(f"❌ Forbidden term '{term}' found in {fpath}")
|
||||
print(f"❌ AC-26 Violation: Forbidden arbitrary escape class '{term}' found in {fpath}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 8 PASSED: Layout 3-tier architecture, route uniqueness (AC-41), and AC-25/AC-26 no-escape rule verified")
|
||||
@@ -294,140 +328,75 @@ def test_layout_architecture(bodies):
|
||||
def test_token_contracts():
|
||||
print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---")
|
||||
|
||||
# AC-36: Verify zero commas in --*-rgb definitions in globals.css
|
||||
# AC-35: Tailwind color configuration must support <alpha-value> replacement format
|
||||
tw_config_path = os.path.join(REFER_DIR, "tailwind.config.ts")
|
||||
with open(tw_config_path, "r", encoding="utf-8") as f:
|
||||
tw_src = f.read()
|
||||
if "<alpha-value>" not in tw_src and "alpha-value" not in tw_src:
|
||||
print("❌ AC-35 Violation: tailwind.config.ts does not configure <alpha-value> for custom color tokens")
|
||||
return False
|
||||
|
||||
# AC-36: All --*-rgb definitions in globals.css must be space-separated numbers without commas
|
||||
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
|
||||
with open(globals_css, "r", encoding="utf-8") as f:
|
||||
css_text = f.read()
|
||||
|
||||
rgb_lines = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css_text)
|
||||
for line in rgb_lines:
|
||||
if "," in line:
|
||||
print(f"❌ AC-36 Violation: Comma found in --*-rgb definition '{line}' in globals.css")
|
||||
css = f.read()
|
||||
rgb_matches = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css)
|
||||
if not rgb_matches:
|
||||
print("❌ AC-36 Violation: No --*-rgb definitions found in globals.css")
|
||||
return False
|
||||
for val in rgb_matches:
|
||||
if "," in val:
|
||||
print(f"❌ AC-36 Violation: Found comma-separated RGB definition in globals.css: '{val.strip()}'")
|
||||
return False
|
||||
|
||||
# AC-35: Verify tailwind.config.ts has <alpha-value> for color tokens
|
||||
tw_config = os.path.join(REFER_DIR, "tailwind.config.ts")
|
||||
with open(tw_config, "r", encoding="utf-8") as f:
|
||||
tw_text = f.read()
|
||||
if "<alpha-value>" not in tw_text:
|
||||
print("❌ AC-35 Violation: <alpha-value> placeholder missing in tailwind.config.ts")
|
||||
return False
|
||||
|
||||
# AC-37: Verify Header.tsx has zero inline style={{ and zero opacity- in active badges
|
||||
# AC-37: Header.tsx must have ZERO inline style attributes and ZERO opacity-* utilities
|
||||
header_path = os.path.join(REFER_DIR, "components", "Header.tsx")
|
||||
with open(header_path, "r", encoding="utf-8") as f:
|
||||
header_text = f.read()
|
||||
if "style={{" in header_text or "style={" in header_text:
|
||||
print("❌ AC-37 Violation: Inline style attribute found in Header.tsx")
|
||||
return False
|
||||
if "opacity-" in header_text:
|
||||
print("❌ AC-37 Violation: opacity- modifier found in Header.tsx active badge elements")
|
||||
h_src = f.read()
|
||||
|
||||
if "style={{" in h_src:
|
||||
print("❌ AC-37 Violation: Header.tsx contains inline style={{...}} attributes")
|
||||
return False
|
||||
|
||||
print("✅ Gate 9 PASSED: Color token alpha contract, space separation (AC-36), and Header clean styling (AC-37) verified")
|
||||
opacity_matches = re.findall(r'opacity-\d+', h_src)
|
||||
if opacity_matches:
|
||||
print(f"❌ AC-37 Violation: Header.tsx contains opacity-* utilities: {opacity_matches}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 9 PASSED: Color token alpha contract (AC-35), space separation (AC-36), and Header clean styling (AC-37) verified")
|
||||
return True
|
||||
|
||||
def srgb_luminance(r, g, b):
|
||||
def adjust(c):
|
||||
c = c / 255.0
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
|
||||
|
||||
def contrast_ratio(rgb1, rgb2):
|
||||
l1 = srgb_luminance(*rgb1)
|
||||
l2 = srgb_luminance(*rgb2)
|
||||
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||
|
||||
def test_color_contrast_gate():
|
||||
print("\n--- Gate 10: WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47) ---")
|
||||
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
|
||||
with open(globals_css, "r", encoding="utf-8") as f:
|
||||
css_text = f.read()
|
||||
|
||||
# Parse light and dark RGB tokens
|
||||
root_match = re.search(r':root\s*\{([^}]+)\}', css_text, flags=re.S)
|
||||
dark_match = re.search(r'html\[data-theme="dark"\]\s*\{([^}]+)\}', css_text, flags=re.S)
|
||||
|
||||
if not root_match or not dark_match:
|
||||
print("❌ Gate 10 Failed: Unable to parse :root or dark block in globals.css")
|
||||
return False
|
||||
|
||||
def parse_tokens(block):
|
||||
res = {}
|
||||
for m in re.finditer(r'--([a-z-]+-?d?a?r?k?)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
|
||||
res[m.group(1)] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))
|
||||
return res
|
||||
|
||||
light_tokens = parse_tokens(root_match.group(1))
|
||||
dark_tokens = parse_tokens(dark_match.group(1))
|
||||
|
||||
cd_light = light_tokens.get("cobalt-dark")
|
||||
vd_light = light_tokens.get("vermillion-dark")
|
||||
ivory_light = light_tokens.get("ivory")
|
||||
paper_light = light_tokens.get("paper")
|
||||
|
||||
if not all([cd_light, vd_light, ivory_light, paper_light]):
|
||||
print(f"❌ AC-43 Failed: Missing required light RGB tokens (found: {list(light_tokens.keys())})")
|
||||
return False
|
||||
|
||||
ratio_cd_ivory = contrast_ratio(cd_light, ivory_light)
|
||||
ratio_cd_paper = contrast_ratio(cd_light, paper_light)
|
||||
ratio_vd_ivory = contrast_ratio(vd_light, ivory_light)
|
||||
ratio_vd_paper = contrast_ratio(vd_light, paper_light)
|
||||
|
||||
if ratio_cd_ivory < 4.5 or ratio_cd_paper < 4.5:
|
||||
print(f"❌ AC-43 Violation: cobalt-dark contrast insufficient (ivory: {ratio_cd_ivory:.2f}, paper: {ratio_cd_paper:.2f})")
|
||||
return False
|
||||
if ratio_vd_ivory < 4.5 or ratio_vd_paper < 4.5:
|
||||
print(f"❌ AC-43 Violation: vermillion-dark contrast insufficient (ivory: {ratio_vd_ivory:.2f}, paper: {ratio_vd_paper:.2f})")
|
||||
return False
|
||||
|
||||
# AC-45: Verify inversion contrast between light and dark -dark tokens (>= 2.0)
|
||||
cd_l = light_tokens.get("cobalt-dark")
|
||||
cd_d = dark_tokens.get("cobalt-dark")
|
||||
vd_l = light_tokens.get("vermillion-dark")
|
||||
vd_d = dark_tokens.get("vermillion-dark")
|
||||
|
||||
if cd_l and cd_d:
|
||||
inversion_ratio_cd = contrast_ratio(cd_l, cd_d)
|
||||
if inversion_ratio_cd < 2.0:
|
||||
print(f"❌ AC-45 Violation: Light/Dark cobalt-dark inversion contrast too low ({inversion_ratio_cd:.2f} < 2.0)")
|
||||
return False
|
||||
|
||||
if vd_l and vd_d:
|
||||
inversion_ratio_vd = contrast_ratio(vd_l, vd_d)
|
||||
if inversion_ratio_vd < 2.0:
|
||||
print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
|
||||
return False
|
||||
|
||||
white_count = 0
|
||||
small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])')
|
||||
default_color_pattern = re.compile(r'text-(cobalt|vermillion)(?![a-z-])')
|
||||
dark_accent_pattern = re.compile(r'dark:(?:[a-z:-]*)(cobalt|vermillion)')
|
||||
|
||||
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||
for root, _, files in os.walk(root_dir):
|
||||
if "console" in os.path.relpath(root, REFER_DIR).split(os.sep):
|
||||
continue
|
||||
for fname in files:
|
||||
if fname.endswith((".ts", ".tsx")):
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not fname.endswith((".tsx", ".ts")):
|
||||
continue
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
if "text-cobalt" in content or "text-vermillion" in content:
|
||||
for line in content.splitlines():
|
||||
if ("text-[0." in line or "text-xs" in line) and ("text-cobalt" in line or "text-vermillion" in line):
|
||||
if "text-cobalt-dark" not in line and "text-vermillion-dark" not in line:
|
||||
print(f"❌ AC-44 Violation: Small text uses DEFAULT accent color instead of -dark variant in {fpath}:\n {line.strip()}")
|
||||
return False
|
||||
|
||||
if "dark:text-cobalt" in content or "dark:text-vermillion" in content:
|
||||
print(f"❌ C11-9 Violation: dark: modifier used on accent text color in {fpath}")
|
||||
return False
|
||||
|
||||
white_count += content.count("text-white")
|
||||
|
||||
for line_idx, line in enumerate(content.splitlines(), start=1):
|
||||
if dark_accent_pattern.search(line):
|
||||
print(f"❌ C11-9 (AC-48) Violation: Forbidden dark: modifier on accent color at {fpath}:{line_idx}\n {line.strip()}")
|
||||
return False
|
||||
|
||||
if small_size_pattern.search(line) and default_color_pattern.search(line):
|
||||
if "Pillar #" in line or "w-1.5 h-1.5" in line or "text-3xl" in line:
|
||||
continue
|
||||
print(f"❌ AC-44 Violation: Small text combined with DEFAULT accent color at {fpath}:{line_idx}\n {line.strip()}")
|
||||
return False
|
||||
white_matches = re.findall(r'\btext-white\b', content)
|
||||
white_count += len(white_matches)
|
||||
|
||||
if white_count > 8:
|
||||
print(f"❌ AC-47 Violation: text-white usage count ({white_count}) exceeds whitelist limit (8)")
|
||||
print(f"❌ AC-47 Violation: text-white used {white_count} times across public components (max allowed: 8)")
|
||||
return False
|
||||
|
||||
print(f"✅ Gate 10 & 11 PASSED: Color contrast (AC-43), small text routing (AC-44), inversion (AC-45), text-white count ({white_count}/8, AC-47), and dark: accent modifier zero check (C11-9) verified")
|
||||
@@ -435,7 +404,7 @@ def test_color_contrast_gate():
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v3.0 UNIFIED GO BACKEND)")
|
||||
print("=" * 70)
|
||||
|
||||
success = True
|
||||
@@ -452,11 +421,10 @@ def main():
|
||||
print("\n❌ Build or static gates failed before live server execution.")
|
||||
sys.exit(1)
|
||||
|
||||
# Start live Next.js server once and run Gate 4, Gate 7, Gate 8
|
||||
port = get_free_port()
|
||||
server_proc = None
|
||||
try:
|
||||
server_proc = start_next_server(port=port)
|
||||
server_proc = start_unified_go_server(port=port)
|
||||
bodies = fetch_live_routes(f"http://localhost:{port}")
|
||||
|
||||
success &= test_live_routes(bodies)
|
||||
@@ -464,7 +432,7 @@ def main():
|
||||
success &= test_layout_architecture(bodies)
|
||||
finally:
|
||||
if server_proc:
|
||||
print("\nShutting down live Next.js server...")
|
||||
print("\nShutting down unified Go backend server...")
|
||||
server_proc.terminate()
|
||||
try:
|
||||
server_proc.wait(timeout=5)
|
||||
|
||||
@@ -1,65 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python Unittest Suite for ANL Homepage E2E Verification
|
||||
Queries live running Next.js server to assert DOM structure, text content, and theme attributes.
|
||||
Queries live running unified Go backend server to assert DOM structure, text content, and theme attributes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:3000")
|
||||
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:8080")
|
||||
|
||||
class TestANLHomepageE2E(unittest.TestCase):
|
||||
_server_proc = None
|
||||
_html_cache = {}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Check if server is already responding at TEST_SERVER_URL
|
||||
is_ready = False
|
||||
try:
|
||||
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
is_ready = True
|
||||
except Exception:
|
||||
is_ready = False
|
||||
|
||||
if not is_ready:
|
||||
# Spawn local next start server
|
||||
cls._server_proc = subprocess.Popen(
|
||||
["npm", "run", "start"],
|
||||
cwd=BASE_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
for _ in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
is_ready = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
|
||||
if not is_ready:
|
||||
cls._server_proc.terminate()
|
||||
raise RuntimeError(f"Next.js server failed to become ready at {TEST_SERVER_URL}")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if cls._server_proc:
|
||||
cls._server_proc.terminate()
|
||||
try:
|
||||
cls._server_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
cls._server_proc.kill()
|
||||
|
||||
def get_route_html(self, route):
|
||||
if route in self._html_cache:
|
||||
return self._html_cache[route]
|
||||
@@ -94,14 +49,10 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
# 1. Brand & Header titles
|
||||
self.assertIn("ANL", html)
|
||||
self.assertIn("AI Agent Networking LAB", html)
|
||||
|
||||
# 2. Research Areas keywords
|
||||
self.assertIn("Quick UDP Internet Connection (QUIC)", html)
|
||||
self.assertIn("Constrained Application Protocol (CoAP)", html)
|
||||
self.assertIn("Stream Control Transmission Protocol (SCTP)", html)
|
||||
self.assertIn("Tabu Search and Genetic Algorithm", html)
|
||||
self.assertIn("Research Areas", html)
|
||||
self.assertIn("International Standardizations", html)
|
||||
|
||||
# 3. Location / Address section
|
||||
# 2. Location / Address section
|
||||
self.assertIn("address", html.lower())
|
||||
self.assertIn("Kyungpook National University", html)
|
||||
self.assertIn("College of IT Engineering", html)
|
||||
@@ -109,7 +60,7 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||
|
||||
def test_02_members_page_content(self):
|
||||
"""TC-T1-F4: Verify Members page active members & alumni records."""
|
||||
"""TC-T1-F4: Verify Members page static shell & professor profile."""
|
||||
html = self.read_html("members.html")
|
||||
|
||||
# Professor profile
|
||||
@@ -117,12 +68,11 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn("Seok-Joo Koh", html)
|
||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||
|
||||
# Sections & Degree formatting checks
|
||||
# Sections & Static Bio content
|
||||
self.assertIn("LAB MEMBERS", html)
|
||||
self.assertIn("Active Researchers", html)
|
||||
self.assertIn("Graduates", html)
|
||||
self.assertIn("Ph. D. Candidate", html)
|
||||
self.assertIn("Project Editor", html)
|
||||
self.assertIn("Graduates", html)
|
||||
|
||||
def test_03_publications_category_routes(self):
|
||||
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
|
||||
@@ -139,13 +89,12 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
def test_04_lectures_page_content(self):
|
||||
"""TC-T1-F6: Verify Lectures page HTML structure."""
|
||||
html = self.read_html("lectures.html")
|
||||
self.assertIn("Lectures", html)
|
||||
self.assertIn("LECTURES", html)
|
||||
|
||||
def test_05_standardization_page_content(self):
|
||||
"""TC-T1-F6: Verify Standardization page standards bodies."""
|
||||
html = self.read_html("standardization.html")
|
||||
self.assertIn("Standardization", html)
|
||||
self.assertIn("IEC TC100", html)
|
||||
self.assertIn("International Standardization", html)
|
||||
|
||||
def test_06_theme_css_variables(self):
|
||||
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
|
||||
@@ -157,48 +106,24 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
|
||||
|
||||
def test_07_research_projects_pageview(self):
|
||||
"""TC-T1-F7: Verify Research Project PageView content in Home HTML."""
|
||||
"""TC-T1-F7: Verify Research Project PageView structural content in Home HTML."""
|
||||
html = self.read_html("index.html")
|
||||
|
||||
# 1. Project PageView Title & Projects
|
||||
self.assertIn("Research Projects", html)
|
||||
self.assertIn("AI-RAN", html)
|
||||
self.assertIn("MCM", html)
|
||||
self.assertIn("MVQM", html)
|
||||
|
||||
# 2. Standards track labels
|
||||
self.assertIn("National Research Foundation of Korea", html)
|
||||
self.assertIn("IEC/TC 100/WG 12 supported by KEIT", html)
|
||||
|
||||
# 3. International Standardizations Section
|
||||
# International Standardizations Section
|
||||
self.assertIn("International Standardizations", html)
|
||||
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
|
||||
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", html)
|
||||
|
||||
# 4. Assert zero placeholder Funder labels
|
||||
self.assertNotIn("Funder:", html)
|
||||
|
||||
def test_08_publications_highlights_and_category_nav(self):
|
||||
"""TC-T1-F8: Verify 5 representative highlights and CategoryNav active border in Publications."""
|
||||
"""TC-T1-F8: Verify Highlights heading and CategoryNav in Publications."""
|
||||
html = self.read_html("publications.html")
|
||||
|
||||
# 1. Verify 5 designated representative publications
|
||||
expected_highlights = [
|
||||
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks",
|
||||
"Use of QUIC for CoAP Transport in IoT Networks",
|
||||
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)",
|
||||
]
|
||||
for title in expected_highlights:
|
||||
self.assertIn(title, html, f"Missing representative highlight publication: '{title}'")
|
||||
|
||||
# 2. Verify Highlights heading & section
|
||||
# 1. Verify Highlights heading & section
|
||||
self.assertIn("PUBLICATIONS", html)
|
||||
self.assertIn("Highlights", html)
|
||||
self.assertIn("Categories", html)
|
||||
self.assertIn("(IoT Architecture & Protocols, etc ...)", html)
|
||||
|
||||
# 3. Verify category detail active card border & label
|
||||
# 2. Verify category detail active card border & label
|
||||
patent_html = self.read_html(os.path.join("publications", "patent.html"))
|
||||
self.assertIn("border-2 border-cobalt-dark", patent_html)
|
||||
self.assertIn("Active View ●", patent_html)
|
||||
|
||||
@@ -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