Compare commits
57
Commits
4b49891f25
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37afb7e5e | ||
|
|
a6f094614e | ||
|
|
1150c230b6 | ||
|
|
01c7c97ec3 | ||
|
|
a362e2c070 | ||
|
|
43ef515a3b | ||
|
|
f3972cac27 | ||
|
|
b0bce3be8e | ||
|
|
ca6e85ce9b | ||
|
|
3dc89970c6 | ||
|
|
cda9c6ea4f | ||
|
|
d1213e5c7f | ||
|
|
78a66b7ad4 | ||
|
|
1410dcf89d | ||
|
|
d3dc7f19d9 | ||
|
|
4c78ea819f | ||
|
|
5285e3fd02 | ||
|
|
575fbdb6de | ||
|
|
643ce8376c | ||
|
|
54fe425b3c | ||
|
|
9bdc9bdd9a | ||
|
|
5f87631530 | ||
|
|
0a589bd3a2 | ||
|
|
e532e68abd | ||
|
|
50cad60c07 | ||
|
|
19f5056bef | ||
|
|
56c8e82dfe | ||
|
|
11f44f65da | ||
|
|
c2703c131d | ||
|
|
74b637bcc9 | ||
|
|
3f452468d6 | ||
|
|
dadd14feb1 | ||
|
|
f96f687dd1 | ||
|
|
5041ab1ea8 | ||
|
|
9cb6258d3d | ||
|
|
fb6881070c | ||
|
|
75bf334fec | ||
|
|
f34e9bad57 | ||
|
|
b1640cdac4 | ||
|
|
a2b626b192 | ||
|
|
44c21ab59e | ||
|
|
8eabd7a2db | ||
|
|
b8f1a000fd | ||
|
|
184ff8263c | ||
|
|
1b84803582 | ||
|
|
73c4f8a8cb | ||
|
|
1470806e06 | ||
|
|
a838af4b94 | ||
|
|
7e431de1b7 | ||
|
|
c4298bb60a | ||
|
|
0d58be61a0 | ||
|
|
898afbcad6 | ||
|
|
5526b70144 | ||
|
|
30e5bd8429 | ||
|
|
9725c8ef66 | ||
|
|
b7f2e050e7 | ||
|
|
0b95bb53e0 |
No files matched your search
+11
-92
@@ -1,97 +1,16 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# .env.example — committable template for the multi-agent-mux-* skills
|
||||
#
|
||||
# This file is tracked in git and contains NO secrets. To get a working local
|
||||
# config, copy it to `.env` (which is git-ignored) and edit as needed:
|
||||
#
|
||||
# scripts/generate-env.sh # creates .env from this template if absent
|
||||
# # or manually: cp .env.example .env
|
||||
#
|
||||
# Every variable below is OPTIONAL. The skills already resolve sane defaults
|
||||
# (shown after each `#default:` line), so an unset/commented variable just keeps
|
||||
# the built-in behaviour. Uncomment + edit only the ones you want to override.
|
||||
#
|
||||
# SECURITY: never put real secrets in this template. Secret-bearing vars use a
|
||||
# `replace_me` placeholder — fill them in only in your local `.env`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ===========================================================================
|
||||
# Workspace / runtime paths
|
||||
# ===========================================================================
|
||||
|
||||
# Single source of truth for the agent session registry YAML.
|
||||
#default: <workspace>/.mam/agent-sessions.yaml
|
||||
# AGENT_SESSIONS_YAML=/path/to/workspace/.mam/agent-sessions.yaml
|
||||
|
||||
# Where the monitor (reconcile.sh) keeps its drift-state cache.
|
||||
#default: <workspace>/.cache/multi-agent-mux-monitor
|
||||
# AGENT_SESSIONS_STATE_DIR=/path/to/workspace/.cache/multi-agent-mux-monitor
|
||||
|
||||
# Root directory that holds Claude Code per-project conversation logs (*.jsonl).
|
||||
#default: $HOME/.claude/projects
|
||||
# CLAUDE_PROJECT_DIR=$HOME/.claude/projects
|
||||
|
||||
# Directory scanned for per-session launcher wrappers (~/.local/bin/<session>).
|
||||
#default: $HOME/.local/bin
|
||||
# LOCAL_BIN=$HOME/.local/bin
|
||||
|
||||
# tmux server socket name (`tmux -L <name>`). "default" = the normal tmux server
|
||||
# (no -L). Set this to opt into an isolated server for all skill tmux calls.
|
||||
#default: default
|
||||
# TMUX_SERVER_NAME=default
|
||||
|
||||
# ===========================================================================
|
||||
# delegate-job / MQTT broker
|
||||
# ===========================================================================
|
||||
|
||||
# MQTT broker host the delegate-job publisher/subscriber connects to.
|
||||
#default: broker.hivemq.com
|
||||
# MQTT_BROKER=broker.hivemq.com
|
||||
|
||||
# Broker auth username. Leave unset for anonymous brokers.
|
||||
#default: (unset → anonymous)
|
||||
# MQTT_USERNAME=replace_me
|
||||
|
||||
# Broker auth password. SECRET — fill in only in your local .env, never commit.
|
||||
#default: (unset → anonymous)
|
||||
# MQTT_PASSWORD=replace_me
|
||||
|
||||
# Prefix for generated MQTT client ids (publisher/subscriber/monitor).
|
||||
#default: hermes
|
||||
# MQTT_CLIENT_ID_PREFIX=hermes
|
||||
|
||||
# Path to a CA bundle for TLS broker verification (set MQTT_TLS=1 to use TLS).
|
||||
#default: (unset → no custom CA)
|
||||
# MQTT_CA_CERTS=/path/to/ca.crt
|
||||
|
||||
# Client certificate for mutual-TLS brokers.
|
||||
#default: (unset → no client cert)
|
||||
# MQTT_CERTFILE=/path/to/client.crt
|
||||
|
||||
# Client private key for mutual-TLS brokers. SECRET — keep the key file private.
|
||||
#default: (unset → no client key)
|
||||
# MQTT_KEYFILE=/path/to/client.key
|
||||
|
||||
# Directory for delegate-job audit logs (sits beside .mam/jobs/).
|
||||
#default: <cwd>/.mam/delegate_job_logs
|
||||
# DELEGATE_JOB_LOGS_DIR=/path/to/workspace/.mam/delegate_job_logs
|
||||
|
||||
# ==============================================================================
|
||||
# deploy / distribution source (for forks/mirrors)
|
||||
# AI Agent Networking Lab (ANL) Unified Single Backend Environment Configuration
|
||||
# ==============================================================================
|
||||
# Note: These variables are read from the execution environment by deployment scripts.
|
||||
# Since deploy/install.sh runs before .env exists, you must pass them via export
|
||||
# or prepended variables (e.g. MAM_REPO_URL=... bash deploy/install.sh).
|
||||
# If you run a private mirror, we strongly recommend configuring all three variables.
|
||||
|
||||
# Distribution repository URL (cloned during recovery steps).
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
# MAM_REPO_URL=https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
# 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
|
||||
|
||||
# Distribution archive download URL (used for bootstrap extraction).
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz
|
||||
# MAM_ARCHIVE_URL=https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz
|
||||
# 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=
|
||||
|
||||
# Distribution update/installer script URL.
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh
|
||||
# MAM_INSTALLER_URL=https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh
|
||||
# OPTIONAL: Host port mapping for unified container (default: 8080)
|
||||
APP_PORT=8080
|
||||
+21
@@ -4,6 +4,27 @@
|
||||
# Python virtual environment
|
||||
/.venv/
|
||||
.agents/
|
||||
.claude/
|
||||
|
||||
# Binary zip archives
|
||||
*.zip
|
||||
|
||||
# Local SQLite databases and bind-mount volumes
|
||||
/volumes/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
!backend/db/backup/*.sql
|
||||
.env
|
||||
docker-compose.yaml
|
||||
# >>> MAM managed block (managed by install.sh — do not edit) >>>
|
||||
/.venv/
|
||||
/.mam/
|
||||
/.mam_deploy/
|
||||
/.mam.env
|
||||
/.mam.env.*
|
||||
!/.mam.env.example
|
||||
/.cache/multi-agent-mux-monitor/
|
||||
/.mam-skill-backup.*/
|
||||
CURRENT_JOB.md
|
||||
# <<< MAM managed block <<<
|
||||
@@ -0,0 +1,207 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# .mam.env.example — committable template for the multi-agent-mux-* skills
|
||||
#
|
||||
# This file is tracked in git and contains NO secrets. To get a working local
|
||||
# config, copy it to `.mam.env` (which is git-ignored) and edit as needed:
|
||||
#
|
||||
# scripts/generate-env.sh # creates .mam.env from this template if absent
|
||||
# # or manually: cp .mam.env.example .mam.env
|
||||
#
|
||||
# Every variable below is OPTIONAL. The skills already resolve sane defaults
|
||||
# (shown after each `#default:` line), so an unset/commented variable just keeps
|
||||
# the built-in behaviour. Uncomment + edit only the ones you want to override.
|
||||
#
|
||||
# SECURITY: never put real secrets in this template. Secret-bearing vars use a
|
||||
# `replace_me` placeholder — fill them in only in your local `.mam.env`.
|
||||
# SECURITY NOTE:
|
||||
# The default MQTT broker (broker.hivemq.com) is public and unencrypted. Job event
|
||||
# payloads (including task details) sent to the default broker can be read by anyone.
|
||||
# For production or private workloads, configure a private broker with TLS and Auth below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ===========================================================================
|
||||
# Workspace / runtime paths
|
||||
# ===========================================================================
|
||||
|
||||
# Single source of truth for the agent session registry YAML.
|
||||
#default: <workspace>/.mam/agent-sessions.yaml
|
||||
# AGENT_SESSIONS_YAML=/path/to/workspace/.mam/agent-sessions.yaml
|
||||
|
||||
# Root directory that holds Claude Code per-project conversation logs (*.jsonl).
|
||||
#default: $HOME/.claude/projects
|
||||
# CLAUDE_PROJECT_DIR=$HOME/.claude/projects
|
||||
|
||||
# Directory scanned for per-session launcher wrappers (~/.local/bin/<session>).
|
||||
#default: $HOME/.local/bin
|
||||
# LOCAL_BIN=$HOME/.local/bin
|
||||
|
||||
# Isolated Herdr session socket name (`herdr --session <name>`).
|
||||
#default: mam-<workspace-slug>
|
||||
# HERDR_SESSION_NAME=mam-multi-agent-mux
|
||||
|
||||
# Legacy Herdr server identifier (DEPRECATED: use HERDR_SESSION_NAME instead).
|
||||
# Written into .mam.env by install.sh as an active default.
|
||||
#default: multi-agent-mux
|
||||
# HERDR_SERVER_NAME=multi-agent-mux
|
||||
|
||||
# Path to the Herdr Unix domain socket.
|
||||
#default: $HOME/.config/herdr/herdr.sock
|
||||
# HERDR_SOCKET_PATH=$HOME/.config/herdr/herdr.sock
|
||||
|
||||
# Explicit Herdr workspace scope identifier (`herdr pane list --workspace <id>`).
|
||||
# Hard filters pane/agent resolution to this workspace. When unset, falls back
|
||||
# to $WORKSPACE_ROOT/.mam/herdr_workspace_id or server-global resolution.
|
||||
#default: <unset> (uses persisted .mam/herdr_workspace_id or global)
|
||||
# HERDR_WORKSPACE_ID=w1
|
||||
|
||||
# ===========================================================================
|
||||
# delegate-job / MQTT broker
|
||||
# ===========================================================================
|
||||
|
||||
# MQTT broker host the delegate-job publisher/subscriber connects to.
|
||||
#default: broker.hivemq.com
|
||||
# MQTT_BROKER=broker.hivemq.com
|
||||
|
||||
# MQTT broker port. Written into .mam.env by install.sh as an active default.
|
||||
#default: 1883
|
||||
# MQTT_PORT=1883
|
||||
|
||||
# MQTT reconnect retry interval (seconds).
|
||||
#default: 2
|
||||
# MQTT_RETRY_INTERVAL=2
|
||||
|
||||
# MQTT maximum reconnect retries.
|
||||
#default: 5
|
||||
# MQTT_MAX_RETRIES=5
|
||||
|
||||
# Broker auth username. Leave unset for anonymous brokers.
|
||||
#default: (unset → anonymous)
|
||||
# MQTT_USERNAME=replace_me
|
||||
|
||||
# Broker auth password. SECRET — fill in only in your local .mam.env, never commit.
|
||||
#default: (unset → anonymous)
|
||||
# MQTT_PASSWORD=replace_me
|
||||
|
||||
# Prefix for generated MQTT client ids (publisher/subscriber/monitor).
|
||||
#default: hermes
|
||||
# MQTT_CLIENT_ID_PREFIX=hermes
|
||||
|
||||
# MQTT keepalive interval (seconds). Used by paho-mqtt client connections.
|
||||
#default: 60
|
||||
# MQTT_KEEPALIVE=60
|
||||
|
||||
# Log level for MAM runtime components (DEBUG, INFO, WARN, ERROR).
|
||||
#default: INFO
|
||||
# MAM_LOG_LEVEL=INFO
|
||||
|
||||
# Optional xAI API Key for Grok Build CLI (if ~/.grok/auth.json is not present).
|
||||
#default: (unset → reads ~/.grok/auth.json)
|
||||
# XAI_API_KEY=replace_me
|
||||
|
||||
# Optional multi-provider API keys for OpenCode CLI and OpenAI API key for Codex CLI.
|
||||
#default: (unset → reads ~/.local/share/opencode/auth.json or ~/.codex/auth.json)
|
||||
# ANTHROPIC_API_KEY=replace_me
|
||||
# OPENAI_API_KEY=replace_me
|
||||
# GEMINI_API_KEY=replace_me
|
||||
# GROQ_API_KEY=replace_me
|
||||
|
||||
# Optional override for Codex CLI state / session directory.
|
||||
#default: $HOME/.codex
|
||||
# CODEX_HOME=$HOME/.codex
|
||||
|
||||
# Optional inline JSON permissions configuration for OpenCode.
|
||||
#default: (unset → defaults to --auto CLI bypass)
|
||||
# OPENCODE_PERMISSION='{"*":"allow"}'
|
||||
|
||||
# Retention period (days) for delegate-job event logs.
|
||||
#default: 7
|
||||
# MAM_EVENT_RETENTION_DAYS=7
|
||||
|
||||
# Garbage collection threshold (minutes) for old message buffer logs.
|
||||
#default: 60
|
||||
# MAM_BUFFER_GC_MINUTES=60
|
||||
|
||||
# Marker string used by loop delegation guard (O-3).
|
||||
#default: (built-in guard marker)
|
||||
# MAM_LOOP_GUARD_MARKER=mam_loop_active
|
||||
|
||||
# Path to a CA bundle for TLS broker verification (set MQTT_TLS=1 to use TLS).
|
||||
#default: (unset → no custom CA)
|
||||
# MQTT_CA_CERTS=/path/to/ca.crt
|
||||
|
||||
# Client certificate for mutual-TLS brokers.
|
||||
#default: (unset → no client cert)
|
||||
# MQTT_CERTFILE=/path/to/client.crt
|
||||
|
||||
# Client private key for mutual-TLS brokers. SECRET — keep the key file private.
|
||||
#default: (unset → no client key)
|
||||
# MQTT_KEYFILE=/path/to/client.key
|
||||
|
||||
# Directory for delegate-job audit logs (sits beside .mam/jobs/).
|
||||
#default: <cwd>/.mam/delegate_job_logs
|
||||
# DELEGATE_JOB_LOGS_DIR=/path/to/workspace/.mam/delegate_job_logs
|
||||
|
||||
# Max attempts to poll for pane renderer quiescence in send_keys_safe.
|
||||
#default: 20
|
||||
# SKS_QUIESCENT_TRIES=20
|
||||
|
||||
# Interval (seconds) between pane quiescence capture polls.
|
||||
#default: 0.5
|
||||
# SKS_QUIESCENT_INTERVAL=0.5
|
||||
|
||||
# Consecutive empty captures to conclude unobservable/headless mode early.
|
||||
#default: 3
|
||||
# SKS_EMPTY_GIVEUP=3
|
||||
|
||||
# Minimum columns a pane must retain after a horizontal/column split (2xK layout engine).
|
||||
#default: 15
|
||||
# MAM_MIN_PANE_COLS=15
|
||||
|
||||
# Minimum rows a pane must retain after a vertical split (2xK layout engine).
|
||||
# Set to 0 to disable vertical row constraints (terminal scrollback handles height).
|
||||
#default: 0
|
||||
# MAM_MIN_PANE_ROWS=0
|
||||
|
||||
# Maximum number of columns a workspace may grow to before the engine reports
|
||||
# 'overflow' (which makes lib.sh create a fresh workspace instead of splitting).
|
||||
# Applies to both measured (GUI) and headless 0x0 layouts.
|
||||
#default: 2
|
||||
# MAM_MAX_PANE_COLS=2
|
||||
|
||||
# Maximum number of rows per column before overflow. Default 2 guarantees an
|
||||
# even 2x2 grid; do not raise this until a resize-normalisation pass exists.
|
||||
#default: 2
|
||||
# MAM_MAX_PANE_ROWS=2
|
||||
|
||||
# Kill the session instead of preserving it when TUI readiness times out.
|
||||
#default: 0
|
||||
# MAM_KILL_ON_READY_TIMEOUT=0
|
||||
|
||||
# Number of trailing non-blank pane lines scanned for blocking modal signatures.
|
||||
#default: 8
|
||||
# MAM_DIALOG_TAIL_LINES=8
|
||||
|
||||
# Consecutive empty captures before wait_for_tui_ready declares the pane unobservable.
|
||||
# NOTE: distinct from SKS_EMPTY_GIVEUP, which governs send_keys_safe quiescence (default 3).
|
||||
#default: 5
|
||||
# MAM_READY_EMPTY_GIVEUP=5
|
||||
|
||||
# ==============================================================================
|
||||
# deploy / distribution source (for forks/mirrors)
|
||||
# ==============================================================================
|
||||
# Note: These variables are read from the execution environment by deployment scripts.
|
||||
# Since deploy/install.sh runs before .mam.env exists, you must pass them via export
|
||||
# or prepended variables (e.g. MAM_REPO_URL=... bash deploy/install.sh).
|
||||
# If you run a private mirror, we strongly recommend configuring all three variables.
|
||||
|
||||
# Distribution repository URL (cloned during recovery steps).
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
# MAM_REPO_URL=https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
|
||||
# Distribution archive download URL (used for bootstrap extraction).
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz
|
||||
# MAM_ARCHIVE_URL=https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz
|
||||
|
||||
# Distribution update/installer script URL.
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh
|
||||
# MAM_INSTALLER_URL=https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh
|
||||
@@ -68,3 +68,9 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi-agent-mux-loop/SKILL.md](.agents/skills/multi-agent-mux-loop/SKILL.md) first before working and follow the instructions for orchestration and collaboration.
|
||||
|
||||
## 5. Orchestrator Scope Guard (O-3)
|
||||
|
||||
**Normal vs. Orchestration Modes**
|
||||
- **Normal Mode**: The orchestrator acts as Main Creator and may edit files directly.
|
||||
- **Loop Active Mode (`/multi-agent-mux-loop`)**: Direct file mutation is intercepted by `.agents/hooks.json`. Do not seek workarounds when a tool call is denied — delegate the task immediately via `run_loop.sh`.
|
||||
@@ -0,0 +1,366 @@
|
||||
# AI Agent Networking Lab (ANL) — REST API Interface Specification
|
||||
|
||||
This document defines the RESTful API endpoints, request parameters, response schemas, and data structures provided by the ANL Go/Gin backend server.
|
||||
|
||||
---
|
||||
|
||||
## 1. General API Conventions
|
||||
|
||||
### 1.1 Base URL
|
||||
- **Local Development**: `http://localhost:8080/api/v1`
|
||||
- **Protocol**: HTTP/1.1 (JSON Content-Type: `application/json; charset=utf-8`)
|
||||
|
||||
### 1.2 Standard Response Envelope
|
||||
|
||||
All API endpoints strictly follow the uniform JSON response envelope structure.
|
||||
|
||||
#### Success Response (`200 OK`)
|
||||
```json
|
||||
{
|
||||
"data": <Payload Object | Array>
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response (`4xx / 5xx`)
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Human-readable error description"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 HTTP Status Codes
|
||||
| Status Code | Description |
|
||||
|---|---|
|
||||
| `200 OK` | The request succeeded and returned the requested data. |
|
||||
| `400 Bad Request` | Invalid query parameter (e.g., unsupported publication category). |
|
||||
| `404 Not Found` | The requested resource was not found. |
|
||||
| `500 Internal Server Error` | Unexpected server-side failure during database query or processing. |
|
||||
|
||||
---
|
||||
|
||||
## 2. API Endpoints Specification
|
||||
|
||||
### 2.1 System & Health
|
||||
|
||||
#### `GET /api/v1/health`
|
||||
Checks backend API service health and database availability.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"service": "anl-backend-api",
|
||||
"status": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Home & Overview
|
||||
|
||||
#### `GET /api/v1/research-projects`
|
||||
Retrieves the list of all active research projects.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "iot-standards",
|
||||
"title": "AIoT & AI-RAN",
|
||||
"abstract": "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.",
|
||||
"keywords": [
|
||||
"AIoT",
|
||||
"agent-based IoT",
|
||||
"AI-RAN",
|
||||
"constrained edge protocols",
|
||||
"distributed autonomous control"
|
||||
],
|
||||
"organization": "National Research Foundation of Korea (NRF)",
|
||||
"standards_org": "NRF",
|
||||
"period": "2026 ~ Present",
|
||||
"funder": null,
|
||||
"created_at": "2026-08-24T16:47:14Z",
|
||||
"updated_at": "2026-08-24T16:47:14Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/research-areas`
|
||||
Retrieves the list of 14 core research areas ordered by `display_order`.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name_en": "Quick UDP Internet Connection (QUIC)",
|
||||
"name_kr": null,
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name_en": "QUIC-based Mobility and Multi-Streaming Support",
|
||||
"name_kr": null,
|
||||
"display_order": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/stats/summary`
|
||||
Retrieves aggregated top-level metrics for the homepage hero section.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"intl_publications": 68,
|
||||
"standardization_docs": 44,
|
||||
"patents": 75
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Members & Alumni
|
||||
|
||||
#### `GET /api/v1/members`
|
||||
Retrieves all 16 active members (including the professor and graduate researchers) ordered by `display_order`.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name_ko": "고석주",
|
||||
"name_en": "Seok-Joo Koh",
|
||||
"degree": "Professor",
|
||||
"affiliation": "School of Computer Science and Engineering",
|
||||
"email": "sjkoh@knu.ac.kr",
|
||||
"is_advisor": true,
|
||||
"display_order": 1,
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name_ko": "김경호",
|
||||
"name_en": "Kyeong-Ho Kim",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 2,
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/alumni`
|
||||
Retrieves all 60 alumni sorted chronologically by `graduated_at DESC`.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name_ko": "도경진",
|
||||
"name_en": "Gyeong-Jin Do",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2024-02",
|
||||
"major": "School of Electronic and Electrical Engineering",
|
||||
"current_position": "LIG Nex1, Senior Researcher",
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Publications & Intellectual Property
|
||||
|
||||
#### `GET /api/v1/publications`
|
||||
Retrieves list of academic publications. Supports filtering by publication category.
|
||||
|
||||
- **Query Parameters**:
|
||||
- `category` (optional, string): Filter by category. Allowed values: `intl-journal-conf`, `domestic-journal-conf`.
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"category": "intl-journal-conf",
|
||||
"title": "Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"authors": "Seok-Joo Koh, ...",
|
||||
"venue": "IEEE Transactions on Network and Service Management",
|
||||
"volume": "Vol. 23, pp. 4490~4505, April 2026",
|
||||
"published_at": "2026-04",
|
||||
"kci": false,
|
||||
"doi": "10.1109/TNSM.2026.10908861",
|
||||
"is_highlight": true,
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/publications/highlights`
|
||||
Retrieves the **5 designated top representative publications** (`WHERE is_highlight = 1`).
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"category": "intl-journal-conf",
|
||||
"title": "Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"authors": "Seok-Joo Koh, ...",
|
||||
"venue": "IEEE Transactions on Network and Service Management",
|
||||
"volume": "Vol. 23, pp. 4490~4505, April 2026",
|
||||
"published_at": "2026-04",
|
||||
"kci": false,
|
||||
"doi": "10.1109/TNSM.2026.10908861",
|
||||
"is_highlight": true,
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"category": "intl-journal-conf",
|
||||
"title": "Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G",
|
||||
"authors": "Seok-Joo Koh, ...",
|
||||
"venue": "IEEE Transactions on Consumer Electronics",
|
||||
"volume": "Vol. 71, pp. 1120~1135, Jan. 2026",
|
||||
"published_at": "2026-01",
|
||||
"kci": false,
|
||||
"doi": "10.1109/TCE.2025.3524673",
|
||||
"is_highlight": true,
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/patents`
|
||||
Retrieves all 75 registered and pending patents sorted by `published_at DESC`.
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "다중 인터페이스 기반 QUIC 핸드오버 지원 장치 및 방법",
|
||||
"inventors": "고석주, 김경호",
|
||||
"application_no": "10-2024-0012345",
|
||||
"application_at": "2024-01-15",
|
||||
"registration_no": "10-2567890",
|
||||
"registration_at": "2025-05-20",
|
||||
"country": "KR",
|
||||
"published_at": "2025-05-20",
|
||||
"created_at": "2026-08-24T16:47:14Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Lectures & Curriculum
|
||||
|
||||
#### `GET /api/v1/semesters`
|
||||
Retrieves all 45 academic semesters with nested course lists, ordered by `year DESC` and `term` (Fall before Spring).
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"year": 2026,
|
||||
"term": "Spring",
|
||||
"courses": [
|
||||
{
|
||||
"id": 1,
|
||||
"semester_id": 1,
|
||||
"code": "COMP0411",
|
||||
"name_en": "Computer Networks",
|
||||
"name_kr": null,
|
||||
"note": "Undergraduate Core",
|
||||
"display_order": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"year": 2025,
|
||||
"term": "Fall",
|
||||
"courses": [
|
||||
{
|
||||
"id": 2,
|
||||
"semester_id": 2,
|
||||
"code": "COMP0754",
|
||||
"name_en": "Advanced Internet Protocol",
|
||||
"name_kr": null,
|
||||
"note": "Graduate Course",
|
||||
"display_order": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.6 Standardization
|
||||
|
||||
#### `GET /api/v1/standards-bodies`
|
||||
Retrieves all 6 standardization bodies with 3-tier hierarchy (`Body → Project (WG) → Documents[]`).
|
||||
|
||||
- **Response Payload**:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"org": "IEC TC100",
|
||||
"full_name": "International Electrotechnical Commission: Technical Committee 100",
|
||||
"scope": "international",
|
||||
"period": "2018 ~ Present",
|
||||
"role": "Project Editor / Active Contributor",
|
||||
"display_order": 1,
|
||||
"projects": [
|
||||
{
|
||||
"wg": "WG 12",
|
||||
"name": "MCM: Multilateral and Collaborative Metaverse",
|
||||
"documents": [
|
||||
{
|
||||
"id": 1,
|
||||
"body_id": 1,
|
||||
"wg": "WG 12",
|
||||
"project_name": "MCM: Multilateral and Collaborative Metaverse",
|
||||
"title": "Conceptual model for multilateral and collaborative metaverse systems",
|
||||
"doc_ref": "IEC 63430 ED1",
|
||||
"status": "IS",
|
||||
"published_at": "2026-03"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# AI Agent Networking Lab (ANL) — System Architecture Specification
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
AI Agent Networking Lab (ANL) 시스템은 연구실의 학술 실적(논문, 특허), 연구 과제, 구성원, 강의, 표준화 기고서 등의 데이터를 효율적으로 관리하고 제공하기 위한 **하이브리드 데이터 아키텍처(Decoupled Static-Export & Headless API Architecture)**로 설계되었습니다.
|
||||
|
||||
Next.js 기반의 고성능 정적 웹사이트(SSG)와 Go/Gin 및 SQLite 기반의 독립형 헤드리스(Headless) 백엔드 API 서버를 분리하여, **완벽한 정적 빌드 성능(0 Dynamic Route)**과 **단일 진실 공급원(Single Source of Truth) 기반의 데이터 관리 편의성**을 동시에 달성합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. High-Level Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Storage & Ingestion Layer"
|
||||
SEED["Seed Data JSON<br/>(backend/seed/data/*.json)"] -->|cmd/seed| SQLITE[("SQLite Database<br/>(anl.db - WAL Mode)")]
|
||||
SQLITE -->|cmd/export-content| TS_CONTENT["Static TypeScript Content<br/>(refer_landing_page/content/*.ts)"]
|
||||
end
|
||||
|
||||
subgraph "Backend API Serving Layer (Go / Gin)"
|
||||
API_MAIN["API Server Binary<br/>(backend/cmd/api)"] --> ROUTER["Gin Engine & Router<br/>(internal/router)"]
|
||||
ROUTER --> HANDLERS["Domain Handlers<br/>(internal/handlers)"]
|
||||
HANDLERS --> REPO["Repository Layer<br/>(internal/repository)"]
|
||||
REPO -->|database/sql + modernc.org/sqlite| SQLITE
|
||||
end
|
||||
|
||||
subgraph "Frontend Presentation Layer (Next.js 14 SSG)"
|
||||
TS_CONTENT --> NEXT_BUILD["Next.js Static Build Engine<br/>(○ Static / ● SSG)"]
|
||||
NEXT_BUILD --> SSG_PAGES["Static HTML Routes<br/>(/, /members, /publications, etc.)"]
|
||||
ADMIN["Future Admin Dashboard<br/>(CRUD Client)"] -.->|REST API /api/v1| ROUTER
|
||||
CLIENT_BROWSER["End Users / Browsers"] -->|Fast CDN / Nginx Serving| SSG_PAGES
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Architectural Principles
|
||||
|
||||
### 3.1 Single Source of Truth (SSOT)
|
||||
- 모든 연구실 데이터(과제, 멤버, 논문, 특허, 강의, 표준화)는 **SQLite 데이터베이스(`anl.db`)**를 단일 원천으로 관리합니다.
|
||||
- 데이터 갱신 시 `cmd/export-content` 도구를 통해 프론트엔드의 `content/*.ts` 파일로 동기화되어 코드와 데이터의 정합성을 보장합니다.
|
||||
|
||||
### 3.2 Zero-CGO Pure Go & Portability
|
||||
- SQLite 드라이버로 **`modernc.org/sqlite`**를 채택하여 CGO 의존성을 완전히 제거했습니다.
|
||||
- C 컴파일러 없이도 단일 실행 바이너리(`bin/api`, `bin/seed`, `bin/export-content`)로 컴파일되며, macOS, Linux, Docker 컨테이너 등 어떤 환경에서도 즉시 구동됩니다.
|
||||
|
||||
### 3.3 High-Performance Read Optimization (WAL Mode)
|
||||
- 랜딩페이지 특성상 읽기(Read) 요청이 99% 이상이므로, SQLite 연결 시 **WAL(Write-Ahead Logging)** 모드와 `busy_timeout=5000`을 적용하여 동시 다발적인 읽기 트랜잭션에서 잠금 경합 없는 고속 처리를 보장합니다.
|
||||
|
||||
### 3.4 Strict Static Build Compliance (DM-03 / AC-17)
|
||||
- 프론트엔드 빌드 시 런타임 API 호출 의존성을 배제하여 Next.js 정적 사전 렌더링(`○ Static` / `● SSG`) 원칙을 100% 준수합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layered Module Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ ├── api/main.go # Gin HTTP REST API 서버 엔드포인트 (:8080)
|
||||
│ ├── seed/main.go # SQLite DDL 마이그레이션 및 JSON 시드 데이터 인제스트 도구
|
||||
│ └── export-content/main.go # SQLite DB 데이터를 프론트엔드 content/*.ts 파일로 내보내는 도구
|
||||
├── internal/
|
||||
│ ├── db/
|
||||
│ │ ├── db.go # SQLite 커넥션 풀 관리 및 PRAGMA 설정 (WAL, foreign_keys)
|
||||
│ │ └── migrations/ # golang-migrate SQL DDL 스크립트 (000001_init.up.sql / down.sql)
|
||||
│ ├── models/ # 도메인별 데이터 구조체 모델
|
||||
│ │ ├── home.go # ResearchProject, ResearchArea, StatsSummary
|
||||
│ │ ├── members.go # Member, Alumnus
|
||||
│ │ ├── publications.go # Publication, Patent
|
||||
│ │ ├── lectures.go # Semester, Course
|
||||
│ │ └── standardization.go # StandardsBody, StandardProject, StandardDocument
|
||||
│ ├── repository/ # database/sql 기반 비즈니스 쿼리 계층 (N+1 방지 일괄 쿼리 최적화)
|
||||
│ ├── handlers/ # Gin HTTP 요청 바인딩 및 JSON 응답 핸들러
|
||||
│ ├── router/ # API 라우트 등록, CORS 미들웨어 및 통합 테스트
|
||||
│ └── httpx/ # 표준 JSON 응답 봉투 ({ data: ... } / { error: ... })
|
||||
├── seed/data/ # 손실 없이 추출된 원본 JSON 데이터셋
|
||||
└── Makefile # 빌드, 테스트, 시드, 실행 자동화 타겟
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow Workflows
|
||||
|
||||
### 5.1 Initial Seeding & Database Bootstrap
|
||||
1. `make seed` 실행
|
||||
2. `cmd/seed`가 SQLite 데이터베이스 파일(`anl.db`)을 생성
|
||||
3. 임베디드된 마이그레이션 DDL(`000001_init.up.sql`)을 실행하여 11개 테이블과 인덱스 생성
|
||||
4. `seed/data/*.json` 파일들을 읽어 트랜잭션 내에서 일괄 삽입
|
||||
5. 각 테이블별 행(Row) 개수 및 5대 대표 논문(`is_highlight=1`) 정합성을 자동 검증
|
||||
|
||||
### 5.2 Content Export & Frontend Build
|
||||
1. 데이터베이스 변경 후 `make export-content` 실행
|
||||
2. `cmd/export-content`가 SQLite에서 최신 데이터를 조회
|
||||
3. `refer_landing_page/content/*.ts` 파일들을 타입 안전한 TypeScript 상수로 덮어쓰기 생성
|
||||
4. Next.js 빌드(`npm run build`)를 통해 15개 정적 HTML 페이지가 100% SSG로 생성
|
||||
|
||||
### 5.3 Live REST API Serving
|
||||
1. `make run` 또는 `./bin/api` 실행 (기본 포트 `:8080`)
|
||||
2. 클라이언트가 `/api/v1/*` 엔드포인트로 HTTP GET 요청
|
||||
3. Gin 라우터가 요청을 파싱하고 적절한 도메인 핸들러로 전달
|
||||
4. 리포지토리 계층이 파라미터화된 SQL 쿼리로 SQLite 조회 후 일관된 JSON Envelope 형식으로 반환
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# AI Agent Networking Lab (ANL) — Backend Database Schema Specification
|
||||
|
||||
This document defines the SQLite database schema and REST API entities to decouple data from the frontend landing page.
|
||||
|
||||
---
|
||||
|
||||
## 1. `/home` (Home & Overview)
|
||||
|
||||
### 1.1 `research_projects` (연구 과제)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `slug` (TEXT, UNIQUE, NOT NULL) — e.g. `iot-standards`, `ccis`, `vlc-iot`
|
||||
- `title` (TEXT, NOT NULL) — e.g. `AIoT & AI-RAN`, `MCM`, `MVQM`
|
||||
- `abstract` (TEXT, NOT NULL)
|
||||
- `keywords` (TEXT, JSON Array) — e.g. `["AIoT", "agent-based IoT", "AI-RAN"]`
|
||||
- `organization` (TEXT) — e.g. `National Research Foundation of Korea (NRF)`, `IEC/TC 100/WG 12 supported by KEIT`
|
||||
- `standards_org` (TEXT) — e.g. `NRF`, `IEC TC100`
|
||||
- `period` (TEXT) — e.g. `2026 ~ Present`
|
||||
- `funder` (TEXT, OPTIONAL)
|
||||
- `created_at` (DATETIME)
|
||||
- `updated_at` (DATETIME)
|
||||
|
||||
### 1.2 `research_areas` (연구 분야)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `name_en` (TEXT, NOT NULL) — e.g. `AI Agent Networking`, `QUIC & Transport Protocol`
|
||||
- `name_kr` (TEXT) — e.g. `AI 에이전트 네트워킹`
|
||||
- `display_order` (INTEGER, DEFAULT 0)
|
||||
|
||||
---
|
||||
|
||||
## 2. `/members` (구성원 및 졸업생)
|
||||
|
||||
### 2.1 `members` (현재 연구원)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `name_ko` (TEXT, NOT NULL) — e.g. `고석주`
|
||||
- `name_en` (TEXT, NOT NULL) — e.g. `Seok-Joo Koh`
|
||||
- `degree` (TEXT, NOT NULL) — `Professor`, `PhD`, `PhDCandidate`, `PhDStudent`, `MSCandidate`, `MSStudent`
|
||||
- `affiliation` (TEXT) — e.g. `School of Computer Science and Engineering`
|
||||
- `email` (TEXT)
|
||||
- `is_advisor` (BOOLEAN, DEFAULT FALSE)
|
||||
- `display_order` (INTEGER, DEFAULT 0)
|
||||
- `created_at` (DATETIME)
|
||||
|
||||
### 2.2 `alumni` (졸업생)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `name_ko` (TEXT, NOT NULL)
|
||||
- `name_en` (TEXT, NOT NULL)
|
||||
- `degree` (TEXT, NOT NULL) — `PhD`, `MS`
|
||||
- `graduated_at` (TEXT, NOT NULL) — `YYYY-MM` (e.g. `2024-02`)
|
||||
- `major` (TEXT) — e.g. `Computer Science and Engineering`
|
||||
- `current_position` (TEXT) — e.g. `Samsung Electronics, Senior Researcher`
|
||||
- `created_at` (DATETIME)
|
||||
|
||||
---
|
||||
|
||||
## 3. `/publications` (연구 실적 & 지식재산권)
|
||||
|
||||
### 3.1 `publications` (학술 논문)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `category` (TEXT, NOT NULL) — `intl-journal-conf`, `domestic-journal-conf`
|
||||
- `title` (TEXT, NOT NULL)
|
||||
- `authors` (TEXT) — e.g. `Seok-Joo Koh, ...`
|
||||
- `venue` (TEXT, NOT NULL) — e.g. `IEEE Transactions on Network and Service Management`
|
||||
- `volume` (TEXT) — e.g. `Vol. 23, pp. 4490~4505, April 2026`
|
||||
- `published_at` (TEXT, NOT NULL) — `YYYY-MM` or `YYYY-MM-DD`
|
||||
- `kci` (BOOLEAN, DEFAULT FALSE)
|
||||
- `doi` (TEXT)
|
||||
- `is_highlight` (BOOLEAN, DEFAULT FALSE) — For Top Representative Highlights
|
||||
- `created_at` (DATETIME)
|
||||
|
||||
### 3.2 `patents` (특허)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `title` (TEXT, NOT NULL)
|
||||
- `inventors` (TEXT, NOT NULL)
|
||||
- `application_no` (TEXT, NOT NULL)
|
||||
- `application_at` (TEXT, NOT NULL) — `YYYY-MM-DD`
|
||||
- `registration_no` (TEXT)
|
||||
- `registration_at` (TEXT) — `YYYY-MM-DD`
|
||||
- `country` (TEXT, NOT NULL) — e.g. `KR`, `US`
|
||||
- `published_at` (TEXT, NOT NULL)
|
||||
- `created_at` (DATETIME)
|
||||
|
||||
---
|
||||
|
||||
## 4. `/lectures` (강의)
|
||||
|
||||
### 4.1 `semesters` (학기)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `year` (INTEGER, NOT NULL) — e.g. `2026`
|
||||
- `term` (TEXT, NOT NULL) — `Spring`, `Fall`
|
||||
|
||||
### 4.2 `courses` (개설 과목)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `semester_id` (INTEGER, NOT NULL, FK -> semesters.id)
|
||||
- `code` (TEXT, NOT NULL) — e.g. `COMP0411`
|
||||
- `name_en` (TEXT, NOT NULL) — e.g. `Computer Networks`
|
||||
- `name_kr` (TEXT)
|
||||
- `note` (TEXT) — e.g. `Graduate Course`
|
||||
|
||||
---
|
||||
|
||||
## 5. `/standardization` (국제 표준화)
|
||||
|
||||
### 5.1 `standards_bodies` (표준화 기구)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `org` (TEXT, NOT NULL) — e.g. `IEC TC100`, `ITU-T SG17`
|
||||
- `full_name` (TEXT, NOT NULL)
|
||||
- `scope` (TEXT, NOT NULL) — `international`, `domestic`
|
||||
- `period` (TEXT, NOT NULL) — e.g. `2018 ~ Present`
|
||||
- `role` (TEXT) — e.g. `Project Editor`
|
||||
|
||||
### 5.2 `standard_documents` (표준 문서 및 기고서)
|
||||
- `id` (INTEGER, PK, AUTOINCREMENT)
|
||||
- `body_id` (INTEGER, NOT NULL, FK -> standards_bodies.id)
|
||||
- `wg` (TEXT, NOT NULL) — e.g. `WG 12`
|
||||
- `project_name` (TEXT, NOT NULL)
|
||||
- `title` (TEXT, NOT NULL)
|
||||
- `doc_ref` (TEXT, NOT NULL) — e.g. `IEC 63430 ED1`
|
||||
- `status` (TEXT, NOT NULL) — `IS`, `TS`, `TR`, `CDV`, `WDTR`, `InProgress`
|
||||
- `published_at` (TEXT)
|
||||
@@ -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)
|
||||
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 스킬 & 룰셋 (MAM)
|
||||
├── .mam/ # ⚡ 에이전트 세션 DB 및 HERDR 격리 레지스트리
|
||||
└── docs/ & research/ # 작업 노트, 결정 기록(ADR) 및 연구 논문 아카이브
|
||||
└── 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% 무결하게 검증됩니다.
|
||||
+351
-10
@@ -4,10 +4,10 @@
|
||||
| 항목 | 내용 |
|
||||
| :--- | :--- |
|
||||
| **문서 ID** | `REQ-ANL-WEB-001` |
|
||||
| **버전** | `1.8` |
|
||||
| **작성일** | 2026-07-30 |
|
||||
| **작성자** | claude (`canary-projects-landing-page-creator-claude`) · Role: planner |
|
||||
| **상태** | 승인 (Approved) — v1.5에서 **D-01** 확정(A·B안 기각 / `intl-conf` 폐기 · 3개 카테고리), v1.6에서 **D-06** 확정(레이아웃 컨테이너 3계층 분리). v1.7에서 **§6.1.2 Research Project PageView** 신설 — 신규 미결 **D-07**(🔴 Funder 실데이터 부재)·**D-08**(🟡 프로젝트 집합 범위)은 각 기본값으로 구현을 차단하지 않는다. 잔여 미결 D-02·D-03·D-05는 🟡 등급으로 각 기본값이 적용되어 승인을 보류하지 않는다 |
|
||||
| **버전** | `2.1` |
|
||||
| **작성일** | 2026-08-24 |
|
||||
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
|
||||
| **상태** | 승인 (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 애플리케이션) 전체 |
|
||||
@@ -110,7 +110,7 @@
|
||||
| :--- | :--- | :--- |
|
||||
| **IA-01** | 현행 `/intro`의 커버 스프레드를 **`/`(Home)으로 승격**한다 | PR-02가 상단 탭에 `Intro`를 포함하지 않으며, PR-03의 "연구실 소개"가 Home의 구성 요소로 명시되어 있다. `/intro`를 별도 유지하면 Home과 콘텐츠가 중복된다. **부수 효과로 기존 `/` 404 결함이 해소된다.** |
|
||||
| **IA-02** | `/intro`는 **`/`로 308 영구 리다이렉트**한다 | 3차에 걸친 리뷰 리포트·문서가 `/intro`를 참조하고 있어 링크 안정성이 필요하다. `next.config.js`의 `redirects()`로 처리하며 별도 페이지 파일을 남기지 않는다. |
|
||||
| **IA-03** *(v1.1 개정 · v1.5 수치 갱신)* | Publications 카테고리는 **라우트 세그먼트 `/publications/[category]` + `generateStaticParams()`** 로 구현한다. `/publications`는 개요 화면으로 유지한다 | **v1.0에서 "단일 라우트 + `?cat=` 쿼리"였으나 §8.5의 실측 검증 결과에 따라 개정.** 세그먼트 방식은 카테고리별 완전한 정적 HTML 3개(D-01 확정 전 4개)를 빌드 타임에 생성해 **공유 링크가 필터 상태를 포함한 문서를 그대로 제공**하며(AC-05 취지 충족), 뒤로/앞으로 가기·프리페치·스크롤 복원이 Next 라우터에서 기본 제공된다. 슬러그를 카테고리 레지스트리에서 도출하므로 **라벨 변경이 레지스트리 한 곳 수정으로 끝난다는 v1.0의 근거는 그대로 유지**된다(→ D-01 리스크 완화 불변). 상단 탭은 5종 그대로이므로 **PR-02와 충돌하지 않는다**(하위 라우트일 뿐 최상위 탭이 아니다). |
|
||||
| **IA-03** *(v1.1 개정 · v1.5 수치 갱신 · v1.9 개정 · v1.10 재개정)* | Publications 카테고리는 **라우트 세그먼트 `/publications/[category]` + `generateStaticParams()`** 로 구현한다. **`/publications`는 `redirect()`가 아니라 자체 콘텐츠를 렌더하는 실체 라우트로 유지하며, 그 콘텐츠는 하위 카테고리 어느 것과도 중복되지 않는 Overview 뷰**(카테고리별 집계 + 최신 실적 하이라이트)로 한다 — D-09 재확정(v1.10). `redirect()` 전용 라우트는 산출 HTML에 `<main>`·`container-content`가 존재하지 않아 **AC-25를 구조적으로 위반**하므로 금지하고, 첫 카테고리 본문을 그대로 재렌더하는 방식은 **동일 DOM 정적 HTML 2본을 생성해 R-8.25를 위반**하므로 금지한다. 상단 `CategoryNav`는 **3개 카테고리 탭만** 노출하며 `All`/`전체` 탭을 두지 않는다(commit `22a31ff` 결정 존중) — Overview에서는 활성 탭이 없는 것이 정상 상태다 | **v1.0에서 "단일 라우트 + `?cat=` 쿼리"였으나 §8.5의 실측 검증 결과에 따라 개정.** 세그먼트 방식은 카테고리별 완전한 정적 HTML 3개(D-01 확정 전 4개)를 빌드 타임에 생성해 **공유 링크가 필터 상태를 포함한 문서를 그대로 제공**하며(AC-05 취지 충족), 뒤로/앞으로 가기·프리페치·스크롤 복원이 Next 라우터에서 기본 제공된다. 슬러그를 카테고리 레지스트리에서 도출하므로 **라벨 변경이 레지스트리 한 곳 수정으로 끝난다는 v1.0의 근거는 그대로 유지**된다(→ D-01 리스크 완화 불변). 상단 탭은 5종 그대로이므로 **PR-02와 충돌하지 않는다**(하위 라우트일 뿐 최상위 탭이 아니다). |
|
||||
| **IA-04** | 내비게이션 항목은 **PR-02의 5개로 고정**한다 | `components/Header.tsx`의 데스크톱 내비가 5개 항목 기준으로 조판되어 있다. 항목 증감 시 헤더 레이아웃 재설계가 선행되어야 한다. |
|
||||
|
||||
---
|
||||
@@ -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 *(v1.1 문구 정정)* | 데이터는 **빌드 타임 정적 상수**로 유지한다. 런타임 fetch·외부 API·DB를 도입하지 않는다. 전 라우트가 **빌드 타임 정적 프리렌더**(`○ Static` 또는 `● SSG`)를 유지해야 하며, `ƒ Dynamic` 라우트가 발생해서는 안 된다 (§8.5 R-8.16) | 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 스키마 정의 (계약)
|
||||
@@ -413,10 +413,23 @@ interface ResearchProject {
|
||||
| :--- | :--- | :--- |
|
||||
| `ink` / `ink-soft` / `ink-mute` | `#0A0A0A` / `#262420` / `#6B655B` | 본문·괘선 |
|
||||
| `ivory` / `paper` / `line` | `#F5F1E8` / `#ECE6D6` / `#D8D1C0` | 종이 배경 / 인셋 / 헤어라인 |
|
||||
| `vermillion` / `vermillion-dark` | `#D9342B` / `#A8231C` | 강조 (Hot) |
|
||||
| `cobalt` / `cobalt-dark` | `#1B3A8A` / `#122863` | 강조 (Cool) |
|
||||
| `vermillion` / `vermillion-dark` | `#D9342B` / `#A8231C` | 강조 (Hot) — **최초 커밋 이래 불변** |
|
||||
| `cobalt` / `cobalt-dark` | `#4E77FF` / `#345AD2` *(v1.13 갱신)* | 강조 (Cool) — Primary |
|
||||
|
||||
**R-8.1** 신규 색 토큰 추가 금지. 색상 값 변경 시 `tailwind.config.ts`와 `app/globals.css`의 `:root` CSS 변수를 **반드시 함께** 수정한다.
|
||||
**실측 대비 (v1.13 기준선 · WCAG 2.1 재계산)**
|
||||
|
||||
| 토큰 | `ivory`(L) | `paper`(L) | `ivory`(D) | `paper`(D) | 라이트↔다크 반전 대비 |
|
||||
| :--- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `cobalt` #4E77FF | 3.45 | 3.12 | 4.82 | 4.37 | **1.00 ❌ (R-8.28 위반)** |
|
||||
| `cobalt-dark` #345AD2 | 5.26 | **4.76** | 6.57 | 5.96 | 2.08 ✅ |
|
||||
| `vermillion` #D9342B | 4.16 | 3.77 | 5.01 | 4.54 | **1.25 ❌ (→ D-13)** |
|
||||
| `vermillion-dark` #A8231C | 6.38 | 5.77 | 6.72 | 6.09 | 2.58 ✅ |
|
||||
|
||||
> **v1.11 갱신 근거**: 커밋 `eaddc4f`가 `cobalt`를 `#1B3A8A` → `#4A7CE8`, `cobalt-dark`를 `#122863` → `#3462CC`로 변경했다. `#4A7CE8`은 사용자 지정 값이므로 유지하되, `cobalt-dark`는 `paper` 배경에서 4.48:1로 미달하므로 **D-10 결정에 따라 `#32549E`(paper 5.82:1)로 재조정**한다. `vermillion`(#D9342B)은 변경된 적이 없다 — `eaddc4f`의 커밋 메시지 `…and secondary to #D9342B`는 현상 유지의 서술이다(→ §12.1 D-10 상세). **v1.12**: 두 강조색의 `-dark` 변형(`#A8231C` / `#32549E`)은 소형 텍스트 전경뿐 아니라 **`text-[0.65rem]` 상태 칩의 배경**으로도 사용된다 — `ivory` 전경만으로 6.38~6.43(라이트) / 6.57~6.72(다크)를 확보한다(→ R-8.26 · R-8.29 · §12.1 D-10 보론).
|
||||
>
|
||||
> **v1.13 갱신 근거**: 커밋 `ca4a7ee`·`8db16e4`가 `cobalt`를 `#4A7CE8` → **`#4E77FF`**, `cobalt-dark`를 D-10 확정값 `#32549E`가 아닌 **`#345AD2`** 로 설정했다. `#4E77FF`는 사용자 지정 Primary이므로 유지하며, `#345AD2`도 `paper` **4.76**으로 R-8.26 바닥값을 충족하므로 **D-10 확정값을 `#345AD2`로 개정**한다(단 여유가 0.26뿐이라 §12.2 R-4로 등재). 다만 **`--cobalt`의 반전 대비가 1.00으로 R-8.28을 다시 위반**하며 — 이는 v1.11에서 D-10의 근본 원인으로 규명한 실패 유형의 재발이다 — AC-45 게이트가 `-dark` 2개 토큰만 검사하도록 구현되어 **검출하지 못했다**(→ R-8.28 개정 · AC-45 개정 · §12.1 D-12 상세). 4개 색 변경 커밋 **전부 본문이 비어 있어 R-8.1·R-8.27의 대비 재산정표 첨부 의무가 4회 연속 미이행**이다.
|
||||
|
||||
**R-8.1** 신규 색 토큰 추가 금지. 색상 값 변경 시 `tailwind.config.ts`와 `app/globals.css`의 `:root` CSS 변수를 **반드시 함께** 수정하며, **R-8.26~R-8.28의 대비·반전 제약을 재산정해 첨부**한다.
|
||||
**R-8.2** `brand` / `accent` 별칭은 레거시 호환 전용이며 신규 코드에서 사용 금지.
|
||||
|
||||
### 8.2 타이포그래피 · 유틸리티 클래스
|
||||
@@ -427,6 +440,7 @@ interface ResearchProject {
|
||||
### 8.3 강조색 시맨틱 계약
|
||||
|
||||
**R-8.5** `vermillion` = Agent Engineering / Orchestration(홀수 항목), `cobalt` = MCP / A2A Protocol(짝수 항목). 목록 렌더 시 인덱스 기반 교차 배정을 따른다.
|
||||
- **[v1.13 · 적용 범위 명시]** 본 계약은 `docs/DESIGN.md` §2 "의미 있는 색(Semantic accent pairing)"의 정본 서술과 **동일한 내용**이며, 목록 교차 배정뿐 아니라 **단일 강조 표면(히어로 강조어·섹션 키커 등)에도 적용되는지**는 확정되지 않았다. 커밋 `2441f35`가 히어로의 `Orchestration` 강조어를 vermillion → cobalt로 변경해 **같은 페이지 안에서 규약이 둘로 갈린 상태**이므로 **D-12로 이관**한다. 어느 쪽으로 확정하든 **코드와 `docs/DESIGN.md`가 같은 값을 말해야 한다**(→ AC-23 · AC-50).
|
||||
|
||||
### 8.4 컴포넌트 재사용
|
||||
|
||||
@@ -481,6 +495,88 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
||||
- **실측 확인**: Tailwind v3는 `extend`로 추가한 스크린도 **min-width 값 기준으로 정렬**해 출력하므로(`1024 → 1240 → 1280 → 1536` 순서 확인), `2xl` 등 상위 브레이크포인트를 덮어쓰는 캐스케이드 사고는 발생하지 않는다. `screens` 전체를 재정의할 필요가 없다.
|
||||
- **주의**: `lg`(1024px)와 `desktop`(1240px)이 공존하므로, **헤더 내 데스크톱/모바일 전환은 `desktop:`으로만 통일**한다. 동일 속성에 `lg:`와 `desktop:`을 혼용하면 1024–1239 px 구간에 의도치 않은 중간 상태가 생긴다(B2가 정확히 이 사고였다).
|
||||
|
||||
### 8.7 색 토큰 알파 계약 (Color Token Alpha Contract) *(v1.9 신설)*
|
||||
|
||||
디자인 토큰은 `app/globals.css`의 CSS 커스텀 프로퍼티와 `tailwind.config.ts`의 `theme.extend.colors`가 **한 쌍으로 동작**한다. 이 쌍의 계약이 깨지면 **lint·tsc·빌드가 모두 통과하는 채로 화면만 무너진다** — 실제로 그 사고가 발생했으므로(§12.1 D-09 상세) 계약을 명문화한다.
|
||||
|
||||
**R-8.21 [필수 · 공백 구분]** `--*-rgb` 커스텀 프로퍼티의 값은 **반드시 공백 구분 3-숫자**로 정의한다(`--ink-rgb: 10 10 10;`). **쉼표 구분(`10, 10, 10`)을 금지한다.**
|
||||
- 근거: Tailwind는 `<alpha-value>`를 포함한 색을 `rgb(var(--ink-rgb) / var(--tw-text-opacity, 1))` 형태로 전개한다. 값이 쉼표 구분이면 치환 결과가 `rgb(10, 10, 10 / 1)`이 되는데, 이는 CSS Color 4의 **현대 문법(공백+슬래시)도 레거시 문법(전량 쉼표)도 아니어서 무효**다. 무효 선언은 *invalid at computed-value time* 으로 처리되어 **해당 속성이 통째로 적용되지 않는다.** 빌드는 성공하고 CSS 파일에도 규칙이 남으므로 **어떤 정적 게이트도 이를 잡지 못한다.**
|
||||
- `--ink: rgb(var(--ink-rgb));` 형태의 파생 변수는 공백 구분에서도 그대로 유효하므로 함께 유지한다.
|
||||
|
||||
**R-8.22 [필수 · `<alpha-value>` 보존]** `theme.extend.colors`의 모든 토큰은 **`rgb(var(--*-rgb) / <alpha-value>)` 형태**로 선언한다. `var(--ink)`처럼 알파 자리표시자가 없는 형태로 축약하지 않는다.
|
||||
- 근거(실측): 알파 자리표시자가 없으면 Tailwind는 슬래시 수식자 유틸리티를 **오류 없이 조용히 생략**한다. 독립 프로브 결과 — `colors.ivory = "var(--ivory)"`일 때 `bg-ivory/85`·`text-ivory/50`·`border-ivory/15`는 **출력 CSS에 단 한 줄도 생성되지 않았고**, `colors.old = "rgb(var(--ivory-rgb) / <alpha-value>)"`일 때는 `rgb(var(--ivory-rgb) / 0.85)`로 정상 생성되었다.
|
||||
- 유틸리티가 생성되지 않으면 배경은 **투명**, 테두리는 Preflight 기본색 **`#e5e7eb`**(팔레트 외 회색), 텍스트는 **부모 색 상속**으로 대체되어 **팔레트를 벗어난 화면**이 된다.
|
||||
|
||||
**R-8.23 [금지 · 토큰 우회]** 디자인 토큰으로 표현 가능한 색을 컴포넌트의 인라인 `style` 속성이나 리터럴 색값(`#ffffff` 등)으로 우회 지정하지 않는다. 인라인 `style`은 다크 모드 변형(`dark:`)보다 **항상 우선**하므로 **테마 반전이 무력화되고 대비 요구(R-9.6)가 조용히 깨진다.**
|
||||
- 활성 내비 배지처럼 대비가 임계에 가까운 요소에는 **`opacity-*` 유틸리티를 사용하지 않는다.** 요소 불투명도는 전경·배경을 함께 합성해 대비를 낮추며, 실측상 `opacity-90`만으로 4.69:1 → 4.05:1(라이트)로 떨어져 AA 미달이 된다.
|
||||
|
||||
**R-8.24 [필수 · 헤더 가로 예산]** 데스크톱 헤더(브랜드 + 내비 + 토글)의 소요 폭은 **1240 px 뷰포트의 가용 폭 1112 px과 최대 폭 상한(78 rem) 적용 시의 가용 폭 1120 px을 모두 초과하지 않아야 한다.** 헤더에 **좌우 패딩·간격·마진을 추가하는 변경은 예산 재계산을 동반**해야 하며, 예산 초과 시 내비 라벨이 2줄로 접히는 회귀(§8.6 B2)가 재발한다.
|
||||
- 실측 기준선(commit `43c75da`): 소요 **1057.6 px** / 가용 1112 px / 여유 **+54.4 px**, 내비 전량 `font-bold` 보정 시 **+22.7 px**.
|
||||
- `container-content`의 최대 폭이 78 rem(1248 px)로 **상한**이므로, 1280 px 이상에서도 가용 폭은 **1120 px에서 고정**된다. 즉 **뷰포트를 넓혀도 헤더 넘침은 해소되지 않는다** — 넓은 화면 전용 마진(`xl:mr-16` 등)은 예산을 악화시키기만 한다.
|
||||
|
||||
**R-8.25 [금지 · 콘텐츠 중복] *(v1.10 신설)*** 서로 다른 두 라우트가 **실질적으로 동일한 `<main>` 콘텐츠**를 정적 HTML로 생성해서는 안 된다.
|
||||
- 근거: 검색 엔진은 중복을 벌점 처리하지는 않으나 **어느 URL을 정본으로 삼을지 스스로 선택**하므로, 내부 링크·사이트맵이 가리키는 URL과 색인되는 URL이 어긋날 수 있다. 또한 산출물 크기가 이유 없이 늘고, 한쪽만 수정되는 **표류(drift) 결함**이 구조적으로 가능해진다.
|
||||
- 인덱스 라우트는 하위 라우트의 본문을 재사용하는 대신 **고유한 집계·요약 콘텐츠**를 갖는다(→ IA-03).
|
||||
- 불가피하게 동일 콘텐츠를 두 URL에 노출해야 한다면 **`metadata.alternates.canonical` 지정을 필수**로 하며, 그 경우에도 R-8.25의 예외임을 §12.1에 결정 항목으로 기록한다.
|
||||
- 자동 검증: **AC-41**.
|
||||
|
||||
**R-8.26 [필수 · 강조색 대비 바닥값] *(v1.11 신설)*** 강조 토큰(`vermillion`·`cobalt`)의 **DEFAULT 변형은 대형 텍스트(≥24 px, 굵게 ≥18.66 px)·도형·테두리·아이콘 전용**이며 **3:1** 이상을 만족한다. **소형 텍스트에는 `-dark` 변형만 사용**하고, `-dark`는 `ivory`·`paper` **양 배경에서 4.5:1 이상**이어야 한다.
|
||||
- `paper`(#ECE6D6)가 `ivory`(#F5F1E8)보다 어두워 **항상 더 엄격한 제약**이므로 바닥값 산정은 `paper` 기준으로 한다. 실측 차이는 약 0.3~0.5 이며, `ivory`만 보고 통과 판정하면 `bg-paper` 카드 내부에서 미달한다.
|
||||
- **[v1.14 개정 · 판정 격자]** 바닥값 판정은 **{라이트, 다크} × {`ivory`, `paper`} 4개 셀 전부**에서 수행한다. **`paper` 기준 산정 원칙은 다크 테마에도 동일하게 적용된다** — 다크 지면 `paper`(`30 28 26`)도 `ivory`(`18 18 18`)보다 밝아 더 엄격하다. v1.12가 지시한 소형 텍스트 라우팅 `text-cobalt-dark dark:text-cobalt`는 **라이트 절반만 `-dark`로 보내고 다크 절반은 DEFAULT로 되돌려**, `bg-paper` 카드 내부 **8곳이 다크 테마에서 4.37로 미달**한다(→ AC-51). 이는 본 규칙을 **한쪽 테마에만 적용한 계획 오류**이며, v1.11 §6.5의 일관성 결함과 같은 형태다.
|
||||
- 실측 근거: `--cobalt: #4A7CE8`은 `ivory` 3.49 / `paper` 3.16으로 **대형 텍스트 기준(3:1)은 충족하나 소형 텍스트 기준은 미달**한다. 반면 `--cobalt-dark: #32549E`는 6.43 / 5.82로 양 기준을 충족하며, `--vermillion-dark: #A8231C`(6.38 / 5.77)와 **동일 등급**이 되어 두 강조색의 `-dark` 슬롯이 대칭을 이룬다.
|
||||
- **[v1.12 추가 · 배지·칩 라우팅]** 강조색을 **배경**으로 쓰는 **비인터랙티브 상태 칩**(`text-[0.65rem]` 등급)은 **`bg-{accent}-dark` + `text-ivory`** 를 사용한다. DEFAULT 배경 위에서는 팔레트 내 전경 중 통과 후보가 **0~1개뿐**이라 설계 여유가 없다 — 실측상 `bg-cobalt`(#4A7CE8) 위 4개 후보(`ivory` 3.49 · `white` 3.93 · `ink-soft` 3.94 · `ink` 5.03) 중 통과는 `ink` **1개뿐**이고, `bg-vermillion`(#D9342B) 위 `ivory`는 **4.16으로 미달**한다. `-dark` 배경은 `ivory` 전경만으로 **6.38~6.43(라이트) / 6.57~6.72(다크)** 를 확보한다.
|
||||
- **[v1.12 추가 · 짝 칩 등급 일치]** 같은 컴포넌트에서 **짝을 이루는 강조색 칩은 동일 변형 등급**을 사용한다. 한쪽만 `-dark`로 내리면 두 칩의 상대휘도 대비가 **1.19 → 1.54로 악화**된다(실측: `vermillion` L=0.1738 ↔ `cobalt-dark` L=0.0949). 양쪽을 `-dark`로 통일하면 **1.01**로 균형이 잡힌다(`vermillion-dark` L=0.0961 ↔ `cobalt-dark` L=0.0949) — 두 강조색의 `-dark` 변형이 접근성 등급뿐 아니라 **휘도까지 대칭**이기 때문이다.
|
||||
- **인터랙티브 내비·토글 요소는 예외**로 DEFAULT 배경 + `text-white dark:text-ivory`(4.69 / 5.01)를 유지한다. 근거는 §12.1 D-10 상세(부류 A/B 구분) 참조.
|
||||
- **[v1.13 개정 · `text-white`의 적용 조건]** 위 예외는 **배경이 DEFAULT 변형일 때에 한정**한다. **`-dark` 배경 위 전경은 `ivory` 단독을 정본**으로 하며 `text-white`·`dark:` 변형을 붙이지 않는다 — 실측 `bg-cobalt-dark` 위 `ivory`는 라이트 **5.26** / 다크 **6.57**로 양 테마를 통과하므로 팔레트 외 색이 불필요하다(→ R-8.29). 커밋 `2441f35`가 `Header.tsx:69,129`·`CategoryNav.tsx:22`의 배경을 `bg-vermillion` → `bg-cobalt-dark`로 바꾸면서 **v1.12가 인정했던 `text-white`의 불가피성이 소멸**했다.
|
||||
- 자동 검증: **AC-42 · AC-43 · AC-44 · AC-47**.
|
||||
|
||||
**R-8.27 [필수 · 색값 변경 시 재측정 의무] *(v1.11 신설)*** `--*-rgb` 값을 변경하는 모든 커밋은 **해당 토큰이 참여하는 전 전경/배경 조합의 대비를 재산정**하고 그 표를 커밋 메시지 또는 리뷰 리포트에 첨부한다.
|
||||
- `docs/DESIGN.md`의 강조색 교체 안내(커스터마이즈 훅)에 **이 제약을 명문화**한다. 현행 안내는 "강조색 2개와 `-dark` 변형만 교체하면 됩니다"라고 **교체 방법만** 적고 **지켜야 할 대비 제약을 적지 않아**, 안내를 그대로 따른 커밋 `eaddc4f`가 라이트 테마 대비를 9.23 → 3.49로 붕괴시켰다. 문서가 사고의 직접 원인이므로 문서 교정이 재발 방지의 일부다.
|
||||
- 색값 변경은 **lint·tsc·빌드·기존 게이트 전량을 통과한 채로** 화면 접근성만 무너뜨린다 — §12.1 D-09 상세의 알파 계약 사고와 **동일한 무성(silent) 실패 유형**이며, 이번이 두 번째 발현이다.
|
||||
|
||||
**R-8.28 [필수 · 명암 반전 계약] *(v1.11 신설)*** `html.dark`에서 반전되는 모든 토큰은 **라이트 값과 다크 값의 상대휘도가 유의미하게 달라야** 한다 — 두 값 사이의 대비가 **2.0 이상**이어야 한다.
|
||||
- 근거(실측): 변경 전 `cobalt`는 라이트 `L=0.0509`(#1B3A8A) ↔ 다크 `L=0.2199`(#4D77FF)로 대비 **3.13**이었으나, `eaddc4f` 이후 라이트 `L=0.2170`(#4A7CE8) ↔ 다크 `L=0.2199`로 대비 **1.01**이 되었다. 즉 **라이트 테마가 다크 테마용 밝기를 그대로 반입**했고, 이것이 R-8.26 미달의 직접 원인이다.
|
||||
- 한 토큰만 반전 계약에서 이탈하면 그 시점에는 반대 테마가 우연히 무사할 수 있으나, **반대 테마 값을 조정하는 순간 양 테마가 동시에 무너진다.**
|
||||
- **[v1.13 개정 · 검사 범위]** 검사 대상은 **`:root`와 `html.dark` 양쪽에 정의된 전 토큰**이다(현행 10종). 일부 토큰만 검사하는 구현은 본 규칙을 충족하지 않는다 — 현행 게이트는 `cobalt-dark`·`vermillion-dark` **2종만** 검사해 **`--cobalt`의 반전 대비 1.00을 검출하지 못했다**. `--vermillion`(1.25)도 미달이나 최초 커밋 이래의 상태이므로 **D-13**으로 분리한다.
|
||||
- 자동 검증: **AC-45**.
|
||||
|
||||
**R-8.29 [필수 · 반전 토큰 쌍의 `dark:` 불필요 원칙] *(v1.12 신설)*** 전경과 배경이 **모두 반전 토큰**(`--*-rgb`로 정의되어 `html.dark`에서 값이 바뀌는 토큰)이면 **`dark:` 변형 없이 양 테마가 성립해야 한다.** 어떤 조합에 `dark:` 변형이 필요하다면 그것은 **반전하지 않는 색(`white`·리터럴 hex 등)이 섞였다는 신호**이므로, 팔레트 외 색을 제거할 수 있는지 **먼저 검토**한다.
|
||||
- 실측 대조: `bg-cobalt-dark text-ivory`는 라이트 **6.43** / 다크 **6.57**로 `dark:` 없이 통과한다. 반면 `bg-cobalt text-ink`는 다크에서 `--ink-rgb`가 `245 241 232`로 반전되어 **3.49로 미달**하므로 `dark:text-ivory`를 수동으로 덧대야 한다 — **반전 계약을 손으로 우회하는 구조**이며 R-8.28의 취지에 역행한다.
|
||||
- `white`가 불가피한 경우(`bg-vermillion` 위 `ivory`가 4.16으로 미달하는 인터랙티브 요소)는 **AC-47 화이트리스트**로 상한을 관리한다. 화이트리스트는 상한이지 허가가 아니다 — 신규 사용처는 먼저 `-dark` 배경으로 대체 가능한지 검토한다.
|
||||
- **[v1.14 강화 · 강조색 `dark:` 금지]** **강조색(`cobalt`·`vermillion` 및 각 `-dark`)에는 `dark:` 변형을 사용하지 않는다.** 반전 토큰 위에 `dark:`를 덧대는 것은 **반전 계약을 손으로 무력화**하는 행위이며, 실제로 `text-cobalt-dark dark:text-cobalt`는 다크 절반을 DEFAULT로 되돌려 **`bg-paper` 위 4.37**을 만든다. `-dark` 단독은 4개 셀 전부(5.26 / 4.76 / 6.57 / 5.96)를 통과한다.
|
||||
- 세 가지 정본 패턴만 사용한다 — ⓐ 대형 텍스트(≥24 px)·도형·테두리·아이콘·SVG: **`text-cobalt` / `bg-cobalt` / `border-cobalt`**(3.12~4.82, 바닥값 3.0), ⓑ 소형 텍스트: **`text-cobalt-dark`**(4.76~6.57, 바닥값 4.5), ⓒ 강조 배경: **`bg-cobalt-dark text-ivory`**(5.26 / 6.57). **세 패턴 모두 `dark:` 변형이 없다.**
|
||||
- 현행 위반 **38건**(`dark:text-cobalt` 28 · `dark:bg-cobalt` 6 · `dark:hover:border-cobalt` 2 · `dark:hover:text-cobalt` 1 · `dark:group-hover:text-cobalt` 1) 전량이 제거 대상이다.
|
||||
- 자동 검증: **AC-47 · AC-48(C11-9) · AC-51**.
|
||||
|
||||
**R-8.30 [필수 · 비텍스트 UI 대비 계약] *(v1.13 신설)*** 테두리·포커스 링·아이콘·상태 도형 등 **비텍스트 UI 구성요소**(WCAG 2.1 §1.4.11)는 인접색 대비 **3:1** 이상을 만족한다.
|
||||
- **슬래시 알파 수식자를 사용한 색은 배경과 합성한 실효색으로 산정**한다. 알파는 대비를 조용히 갉아먹으며 토큰 값만으로는 판정할 수 없다 — 실측: `focus:ring-cobalt/50`은 `cobalt` 자체가 3.45임에도 `ivory` 위 합성 후 **1.80**(다크 2.07), `hover:border-cobalt/60`은 `paper` 위 **1.95**(다크 2.38)로 **양쪽 다 미달**한다.
|
||||
- 이는 §12.1 D-09(알파 계약 붕괴)·§12.1 D-10(반전 계약 이탈)에 이은 **"알파·토큰 조합이 무성으로 대비를 무너뜨리는" 유형의 세 번째 발현**이다. 강조색에 알파 수식자를 붙이는 것은 **원칙적으로 지양**하고, 필요하면 `-dark` 변형을 불투명하게 사용한다.
|
||||
- 통과 확인된 사용처(회귀 방어 기준선): `hover:border-cobalt` 3.12 / `hover:border-cobalt-dark` 4.76 / `hover:border-vermillion` 3.77 / `ring-vermillion` 4.16 / 불릿 `bg-cobalt` 3.45 / 히어로 SVG `var(--cobalt)` 3.45 · `var(--vermillion)` 4.16 (전부 `paper` 또는 `ivory` 기준 라이트값).
|
||||
- 자동 검증: **AC-48**(Gate 11 · C11-5).
|
||||
|
||||
**R-8.31 [필수 · 역할 단위 색 통일 계약] *(v1.13 신설)*** **동일한 클래스 시그니처를 갖는 요소는 단일 색 패턴을 사용한다.** 역할은 파일·행이 아니라 시그니처로 식별한다.
|
||||
- 판정 대상 시그니처(최소): ⓐ 섹션 장식 괘선 `h-px w-8 bg-*`, ⓑ 섹션 키커 `font-mono text-xs … uppercase tracking-widest … font-bold`, ⓒ 강조 배경 배지·칩의 전경, ⓓ 카드 `hover:border-*`, ⓔ 포커스 인디케이터 `ring-*`.
|
||||
- 판정 기준은 **"어떤 색인가"가 아니라 "색 패턴이 하나뿐인가"** 이다. 따라서 색 선택 결정(D-12)이 확정되기 전에도 게이트를 구현·운용할 수 있고, 결정이 바뀌어도 게이트는 수정할 필요가 없다.
|
||||
- 실측 위반: ⓐ `bg-cobalt-dark dark:bg-cobalt` 6곳 ↔ `bg-vermillion` 1곳(`DirectionsBlock.tsx:7`), ⓑ `text-cobalt-dark dark:text-cobalt` 11곳 ↔ `text-vermillion` 1곳(`DirectionsBlock.tsx:8`), ⓒ `text-white dark:text-ivory` 6곳 ↔ `text-ivory` 4곳, ⓓ 4종, ⓔ 2종.
|
||||
- **같은 데이터가 서로 다른 색으로 렌더되는 것도 위반**이다 — 지도교수 이메일 `sjkoh@knu.ac.kr` 한 값이 `members:54`(5.26) · `DirectionsBlock:43`(4.16) · `Footer:53`(paper 위 hover 3.77)에서 **3색 3대비**로 나타난다.
|
||||
- 예외는 **화이트리스트 등재**(R-8.32 형식)로만 허용한다.
|
||||
- 자동 검증: **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라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
|
||||
- 자동 검증: **AC-48 · AC-49**(F2 · F9).
|
||||
|
||||
**R-8.33 [필수 · 토큰 등급 불변식] *(v1.14 신설)*** 각 테마에서 **`-dark` 변형은 해당 테마의 지면 배경(`ivory`·`paper`) 대비가 DEFAULT보다 커야** 한다. DEFAULT 토큰의 값을 조정할 때는 **같은 테마의 `-dark` 값과의 관계를 반드시 함께 검토**한다.
|
||||
- **절대 휘도 순서가 테마 간에 뒤집히는 것은 정상 동작이며 위반이 아니다.** `-dark`는 "절대적으로 어두운 색"이 아니라 **"지면 대비가 더 깊은 변형"** 이므로, 다크 지면에서 대비를 깊게 하려면 더 밝아진다. 실측: 라이트 `cobalt` L=0.2203 > `cobalt-dark` L=0.1270 이지만 다크에서는 `cobalt` L=0.2199 < `cobalt-dark` L=0.3184이며, vermillion 쌍도 동일하게 뒤집힌다. **판정 기준을 절대 휘도로 두면 정상 상태를 위반으로 오판한다.**
|
||||
- 현행 실측(8개 셀 전부 충족): 라이트 `ivory` 3.45→5.26 · 라이트 `paper` 3.12→4.76 · 다크 `ivory` 4.82→6.57 · 다크 `paper` 4.37→5.96 (cobalt), 라이트 `ivory` 4.16→6.38 · 다크 `ivory` 5.01→6.72 (vermillion).
|
||||
- 근거: 개발 팀장(Agy) 이의제기(Job `4962a667`)가 **다크 DEFAULT만 단독 상향하면 계층이 무너짐**을 지적했고 실측으로 확인했다 — 다크 DEFAULT를 L≈0.52로 올리면 다크 `paper` 대비가 **9.2**가 되어 `-dark`(5.96)를 역전한다.
|
||||
- 자동 검증: **AC-43**(정책 표에 등급 관계를 포함).
|
||||
|
||||
---
|
||||
|
||||
## 9. 비기능 요구사항 (Non-Functional Requirements)
|
||||
@@ -555,7 +651,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
||||
**품질 게이트**
|
||||
- [ ] AC-15 `npm run lint` 통과 (경고 0)
|
||||
- [ ] AC-16 `npx tsc --noEmit` 통과
|
||||
- [ ] AC-17 *(v1.1 개정)* `npm run build` 성공. 빌드 리포트에 **`ƒ (Dynamic)` 라우트가 0건**이고 전 라우트가 `○ (Static)` 또는 `● (SSG)`이다 — `●`도 빌드 타임 정적 프리렌더이므로 충족으로 판정한다 (§8.5 R-8.16)
|
||||
- [ ] 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 누락 시 빌드는 성공하나 조판이 무너짐)
|
||||
@@ -577,6 +673,60 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
||||
- [ ] AC-32 *(PJ-05)* 모든 `deliverables[].ref`가 `content/standards.ts`에 실재하는 문서 참조와 일치함을 조인 검증
|
||||
- [ ] AC-33 *(H-02 개정 · AC-02 회귀 방어)* 히어로 재구성 후에도 **연구실 소개 서술 블록 · 주요 연구 분야 그리드 · 오시는 길 · 지표 카운터 3종 · `HeroComposition`** 이 Home 정적 HTML에 **전부 존속**함을 확인
|
||||
- [ ] AC-34 *(v1.8 신설 · PJ-13·PJ-15·PJ-16)* 프로젝트 트랙의 넘침을 **브라우저 실측 수치로 판정**한다 — 데스크톱(≥1240 px)에서 `track.scrollWidth === track.clientWidth`(스크롤 잔재 **0**), 모바일·태블릿에서는 `scrollWidth > clientWidth`가 **정상 동작**이다. 어느 뷰포트에서도 **문서 레벨** 가로 스크롤은 0이어야 한다(AC-19 · R-9.15). 데스크톱 3카드 폭의 서브픽셀 거동은 브라우저 없이는 확정할 수 없으므로 **이 단정으로 판정을 이관**한다
|
||||
- [ ] AC-35 *(v1.9 신설 · R-8.21·R-8.22)* **슬래시 수식자 실효성 게이트** — 소스(`app/**`·`components/**`의 `.tsx`·`.css`)에서 `토큰/불투명도` 형태로 사용된 **모든 색 유틸리티**를 추출하고, 각각에 대응하는 규칙이 **산출 CSS(`.next/static/css/*.css`)에 실제로 존재**함을 자동 게이트로 확인한다. 1건이라도 누락되면 실패한다. *(현행 실측: 사용 26건 / 생성 0건)*
|
||||
- [ ] AC-36 *(v1.9 신설 · R-8.21)* `app/globals.css`의 모든 `--*-rgb` 정의가 **공백 구분**임을 grep 게이트로 확인한다(쉼표 포함 시 실패). 아울러 산출 CSS에 `rgb(<쉼표 목록> / …)` 패턴이 **0건**임을 확인한다
|
||||
- [ ] AC-37 *(v1.9 신설 · R-8.23 · R-9.6)* `components/Header.tsx`에 **인라인 `style` 속성과 리터럴 색값이 0건**이며, 활성 배지 계열 요소에 **`opacity-*` 유틸리티가 0건**임을 grep으로 확인한다
|
||||
- [ ] AC-38 *(v1.9 신설 · R-9.6)* 활성 내비 배지의 **전 텍스트 상태(데스크톱 라벨·번호 / 모바일 `ko`·`en`·번호)** 가 **라이트·다크 양 테마에서 4.5:1 이상**임을 실측 색값으로 계산해 확인한다. 다크 모드 배경은 `--vermillion-rgb: 235 75 66`으로 **반전**되므로 흰색 전경은 3.74:1로 미달한다 — 다크 모드 전경은 `ivory`(반전 시 `18 18 18`, 실측 5.01:1)를 사용한다
|
||||
- [ ] AC-39 *(v1.9 신설 · R-8.24 · AC-19)* 헤더 가로 예산을 **1240 / 1280 / 1440 px** 에서 확인한다 — `header.scrollWidth <= header.clientWidth`이고 내비 항목이 **2줄로 접히지 않아야** 한다(브라우저 실측 판정)
|
||||
- [ ] AC-40 *(v1.9 신설 · IA-03 · AC-25)* 사이트맵에 등재된 **모든 URL**의 산출 HTML이 `redirect()` 전용이 아니라 **`<main>` + `container-content` 를 포함한 실체 문서**임을 확인한다
|
||||
- [ ] AC-41 *(v1.10 신설 · R-8.25)* 산출 정적 HTML 전 라우트의 **`<main>` 내부 콘텐츠를 정규화 해시**해 **동일 해시가 2건 이상 존재하지 않음**을 자동 게이트로 확인한다. `canonical`이 명시된 예외 쌍만 화이트리스트로 허용한다
|
||||
- [ ] AC-42 *(v1.10 신설 · v1.11 개정 · R-9.6 · AC-38 확장)* **vermillion 배경 위 텍스트 전량**이 라이트·다크 양 테마에서 4.5:1 이상임을 확인한다. **"전량"은 Header·`CategoryNav`에 한정되지 않으며, `group-open:`·`hover:` 등 상태 변형으로 적용되는 배경도 포함한다.** 검증 대상은 **두 부류로 나누어 각각의 기대 패턴과 기대 대비를 판정**한다*(v1.12 개정)*.
|
||||
- **부류 A — `text-[0.65rem]` 비인터랙티브 상태 칩**: `app/standardization/page.tsx:40`·`:88` → **`bg-vermillion-dark text-ivory`**(라이트 **6.38** / 다크 **6.72**, `dark:` 변형 없음). R-8.26 배지·칩 라우팅 조항 적용
|
||||
- **부류 B — 인터랙티브 내비·토글**: `components/Header.tsx:69,129`, `CategoryNav.tsx:22`, `app/lectures/page.tsx:89`, `app/members/page.tsx:177` → **`text-white dark:text-ivory`**(라이트 **4.69** / 다크 **5.01**)
|
||||
- **[v1.13 개정 · 부류 B 재산정]** ⓐ `Header.tsx:69,129`·`CategoryNav.tsx:22`는 `2441f35`에서 **배경이 `bg-vermillion` → `bg-cobalt-dark`로 바뀌어** `text-white`의 불가피성이 소멸했다 — R-8.26 v1.13 조항에 따라 **`text-ivory` 단독**(라이트 5.26 / 다크 6.57)으로 정정한다. ⓑ **`app/lectures/page.tsx:89`는 여전히 `group-open:text-ivory`(4.16)로 미교정**이며, 동일 관용구인 `members/page.tsx:177`만 교정되어 **한 쌍이 갈라져 있다**. ⓒ **`app/not-found.tsx:22`를 신규 등재** — `text-ivory hover:bg-vermillion`으로 hover 시 **4.16**이며 v1.12까지의 어떤 목록에도 없었다. 권고 조치는 `hover:bg-vermillion-dark`(→ 6.38)로, `white` 없이 해소된다
|
||||
- *v1.10 기재 정정*: `text-white` on `#D9342B`의 실측은 **4.69:1**이다(직전 수렴 리포트의 4.58:1은 오기 — 판정은 동일하게 통과)
|
||||
- *현행 미교정 4곳의 실측은 모두 **4.16:1***이며, `lectures:89`·`members:177`은 `group-open:` 상태 변형으로 배경이 적용되어 **육안 리뷰에서 누락되기 쉽다**
|
||||
- [ ] AC-43 *(v1.11 신설 · v1.12·v1.13 개정 · R-8.26·R-8.27·R-9.6)* **대비 자동 게이트(Gate 10)** — `app/globals.css`의 `:root`·`html.dark` `--*-rgb` 정의를 파싱해 **양 테마 전 조합의 대비를 계산**하고 정책 표의 바닥값 미만이면 실패한다. 최소 대상: `{ink, ink-soft, ink-mute, vermillion, vermillion-dark, cobalt, cobalt-dark} × {ivory, paper}` 및 `{ink, ivory, white} × {vermillion, vermillion-dark, cobalt, cobalt-dark}`. **`-dark` 변형이 배경으로 쓰이는 경우**(`{ivory, white} × {cobalt-dark, vermillion-dark}`, 양 테마)를 반드시 포함한다 — v1.11 목록은 `-dark`를 전경으로만 다루어 **상태 칩 배경을 검사하지 못했다***(v1.12 개정)*. 정책 표는 스크립트 상단 상수 블록에 두어 규격 변경 시 1곳만 수정되게 한다. *(현행 실측: `cobalt`는 `ivory` 3.49 / `paper` 3.16, `cobalt-dark`는 `paper` 4.48로 **실패해야 한다**)*
|
||||
- **[v1.13 개정 · C10-1]** 현행 구현은 ⓐ 정책 값이 **함수 본문에 흩어져 있어** 상수 블록 요건을 충족하지 않고, ⓑ **토큰 값만 검사하고 소스의 전경/배경 쌍은 보지 않는다** — 따라서 `bg-cobalt-dark text-white`(다크 **2.85**)를 작성해도 통과한다. 검사 대상에 **소스에서 실제로 함께 쓰인 전경/배경 조합**을 추가하고, `text-white`가 전경인 조합을 **양 테마 모두** 계산한다
|
||||
- **[v1.14 개정]** ⓐ **DEFAULT 토큰 × {`ivory`,`paper`} × {라이트,다크}** 를 **3:1 바닥값**으로 명시 추가한다 — 이 검사가 **D-14 A안의 안전망 본체**이며, 브랜드 색이 어느 테마에서든 역할 바닥값 아래로 내려가면 즉시 실패해야 한다. ⓑ **R-8.33 등급 관계**(각 테마 지면 대비에서 `-dark` > DEFAULT)를 정책 표에 포함한다
|
||||
- [ ] AC-44 *(v1.11 신설 · v1.13 개정 · R-8.26·R-8.32)* **소형 텍스트 라우팅 게이트** — `app/**`·`components/**`에서 `text-cobalt`·`text-vermillion`(DEFAULT 변형)이 **소형 텍스트 크기 클래스와 동일 요소에 공존하지 않음**을 확인한다. 부모로부터 크기를 상속받아 정적 판정이 불가능한 케이스는 **화이트리스트에 사유와 함께 등재**한다. *(현행 실측: `text-cobalt` 소형 텍스트 사용처 **12건** + `bg-cobalt` 소형 배지 **2건** 미달)*
|
||||
- **[v1.13 개정 · C10-2]** 현행 구현의 **라인 부분문자열 면제 4종(`"text-vermillion"`·`"text-3xl"`·`"Pillar #"`·`"w-1.5 h-1.5"`)을 전량 제거**하고 R-8.32의 `(파일, 행, 사유)` 화이트리스트로 대체한다. 첫 번째 면제 때문에 **AC-44의 vermillion 절반이 구조적으로 발화하지 않음**을 재현 확인했다(→ R-8.32). 제거 후 `text-vermillion` **23건** 중 소형 텍스트 사용처가 검출되어야 하며, 최저값은 `bg-paper` 카드 내부의 `members/page.tsx:114,144` **3.77**이다
|
||||
- **[v1.14 개정 · G-7]** 판정 정규식의 **부정 후방탐색 `(?<!dark:)` 를 제거**한다. 현행 구현은 이 후방탐색으로 **`dark:text-cobalt`·`dark:text-vermillion`을 설계상 검사 대상에서 제외**하고 있어, 소형 텍스트 라우팅을 **라이트 테마에 대해서만** 검사한다. 그 결과 다크 테마 `bg-paper` 위 **8곳(4.37)이 검사 범위 밖**이었다(→ AC-51). G-1(부분문자열 면제)·G-2(반전 검사 2종 한정)·G-3(전경/배경 쌍 미검사)에 이은 **네 번째 범위 결함이며, 넷 모두 "검사를 절반만 한다"는 같은 형태**다
|
||||
- [ ] AC-45 *(v1.11 신설 · v1.13 개정 · R-8.28)* **반전 계약 게이트** — `:root`와 `html.dark`의 동일 토큰 쌍에 대해 라이트↔다크 상대휘도 대비가 **2.0 이상**임을 확인한다. *(현행 실측: `cobalt`는 **1.01**로 실패해야 한다)*
|
||||
- **[v1.13 개정 · C10-3]** 검사 범위를 **양쪽에 정의된 전 토큰(현행 10종)** 으로 확대한다. 현행 구현은 `cobalt-dark`·`vermillion-dark` **2종만** 검사해 **`--cobalt`의 반전 대비 1.00(라이트 `78 119 255` ↔ 다크 `77 119 255`)을 검출하지 못했다** — v1.11이 D-10의 근본 원인으로 규명한 바로 그 실패 유형의 재발이며, **게이트가 통과했다는 사실 자체가 오탐 신호였다**. 확대 시 `--vermillion`(1.25)도 검출되므로 **D-13 확정 전까지는 화이트리스트에 사유와 함께 등재**한다
|
||||
- **[v1.14 · 브랜드 고정 토큰 예외 후보]** `--cobalt`(1.00)·`--vermillion`(1.25)은 **사용자 지정 브랜드 색**이며, R-8.29 정리(강조색 `dark:` 변형 38건 제거) 후에는 **어느 테마에서도 소형 텍스트를 담지 않는다**. 남는 사용처는 전부 대형 텍스트·도형·테두리·아이콘이며 **양 테마에서 3:1을 충족**한다(최저 3.12). 이 상태에서 R-8.28을 관철하려면 다크 값을 파스텔로 올려야 하는데, 그러면 **브랜드 색이 다크 테마에서 사라진다**. 처리 방침은 **D-14**로 이관한다 — R-8.28은 *수단*이고 **R-8.26·R-8.30(테마별 대비)이 *목적*** 이므로, 목적 규칙이 **양 테마에서 실효적으로 검사되면**(→ AC-43 개정) 안전망은 유지된다. 검증: 브랜드 색을 `#1B3A8A`로 되돌리고 테마 불변을 유지하면 다크 지면 대비가 **1.80**으로 떨어져 **AC-43이 즉시 실패**한다
|
||||
- [ ] AC-46 *(v1.11 신설 · AC-43·AC-44·AC-45 유효성)* **폴트 인젝션으로 Gate 10의 실효성을 증명**한다 — ① `--cobalt-dark-rgb`를 `52 98 204`로 되돌리면 AC-43 실패, ② 소형 텍스트를 `text-cobalt`(DEFAULT)로 되돌리면 AC-44 실패, ③ `html.dark`의 `--cobalt-rgb`를 라이트 값과 동일하게 두면 AC-45 실패, ④ `standardization/page.tsx:40`을 `bg-vermillion text-ivory`로 되돌리면 AC-42 실패, **⑤ 상태 칩을 `bg-cobalt text-ivory`(v1.11 §6.5 폐기안)로 되돌리면 AC-43 실패(3.49)**, **⑥ 임의 위치에 `text-white`를 1건 추가하면 AC-47 실패(9 > 8)** *(⑤·⑥은 v1.12 신설)*. **6건 모두 실패를 재현하지 못하면 Gate 10을 채택하지 않는다** — 통과만으로는 게이트가 결함을 검출한다는 증명이 되지 않는다
|
||||
- ⑤는 **기각·교체된 설계를 게이트가 실제로 잡는지** 확인한다. v1.10에서 기각한 D-09 C안을 주입해 AC-41을 검증한 것과 같은 방식이며, **교체된 설계는 반드시 게이트의 검출 대상이 되어야 한다**
|
||||
- [ ] AC-47 *(v1.12 신설 · v1.13 개정 · R-8.29)* **팔레트 외 색 상한 게이트** — `app/**`·`components/**`에서 `text-white`·`bg-white` 등 **팔레트 외 색 유틸리티의 출현 횟수가 화이트리스트 등재 건수를 초과하지 않음**을 확인한다.
|
||||
- 화이트리스트(부류 B 한정, 총 **8건**): `components/Header.tsx:69,75,129,136,145`(5) · `CategoryNav.tsx:22`(1) · `lectures/page.tsx:89`(1) · `members/page.tsx:177`(1)
|
||||
- 각 항목에 **사유**(해당 위치에서 `ivory` 전경이 4.16으로 미달)를 함께 기재한다. 화이트리스트는 **상한이지 허가가 아니며**, 신규 사용처는 `-dark` 배경으로 대체 가능한지 먼저 검토한 뒤에만 등재한다
|
||||
- 근거: `docs/DESIGN.md`는 "토큰만 사용 — 임의 회색·팔레트 밖 hex 금지"를 규정한다. `white`는 Issue 01 팔레트(`ink`/`ivory`/`paper`/`line`/`vermillion`/`cobalt`) 밖의 색으로, `02247a0`에서 **불가피성 때문에** 도입되었다. 상한을 두지 않으면 그 불가피성이 관행으로 굳는다
|
||||
- **[v1.13 개정 · C10-4]** 현행 구현은 `content.count("text-white")` **단순 계수**라 **위치를 검증하지 않는다.** 화이트리스트를 `(파일, 행)` 집합으로 두고 **실제 출현 위치 집합과 완전히 일치**할 때만 통과시킨다(초과·고아 양방향 검출, R-8.32). 배경 변경(`bg-vermillion` → `bg-cobalt-dark`)으로 **현행 7건 중 6건이 R-8.29상 제거 대상**이 되었으므로, Phase 3 완료 후 화이트리스트는 **`lectures/page.tsx:89`·`members/page.tsx:177` 2건**으로 축소되어야 한다 — 이 2곳만이 DEFAULT `bg-vermillion` 위(ivory 4.16 미달)에 있다
|
||||
|
||||
**색 통일성 (v1.13 신설)**
|
||||
- [ ] AC-48 *(v1.13 신설 · R-8.30·R-8.31·R-8.32)* **색 통일성 게이트(Gate 11)** — 아래 8개 검사를 자동화한다. C11-1~C11-4는 R-8.31, C11-5는 R-8.30, C11-7은 AC-23 회귀 방어에 대응한다.
|
||||
- **C11-1 괘선 통일** — `h-px w-8 bg-*` 시그니처의 색 클래스가 **2종 이상이면 실패** *(현행: `bg-cobalt-dark dark:bg-cobalt` 6곳 ↔ `bg-vermillion` 1곳 → **실패해야 한다**)*
|
||||
- **C11-2 키커 통일** — `font-mono text-xs … uppercase tracking-widest … font-bold` 시그니처의 색 클래스가 2종 이상이면 실패 *(현행 11곳 ↔ 1곳 → **실패해야 한다**)*
|
||||
- **C11-3 강조 배경 전경 통일** — `bg-{accent}-dark` 요소에 `text-white`가 결합되어 있으면 실패(R-8.29) *(현행 6건 → **실패해야 한다**)*
|
||||
- **C11-4 상태 배경 전경 쌍** — `group-open:bg-*`·`hover:bg-*`로 배경이 바뀌는 요소의 대응 상태 전경과의 대비가 4.5 미만이면 실패 *(현행 `lectures:89` 4.16 · `not-found:22` 4.16 → **실패해야 한다**)*
|
||||
- **C11-5 비텍스트 UI 3:1** — `border-{accent}*`·`ring-{accent}*`·도형 `bg-{accent}*`·SVG의 `var(--*)` 참조를 수집하고, **슬래시 알파는 배경과 합성한 뒤** 계산해 3.0 미만이면 실패 *(현행 `focus:ring-cobalt/50` **1.80** · `hover:border-cobalt/60` **1.95** → **실패해야 한다**)*
|
||||
- **C11-6 전역 CSS 조합** — `globals.css`의 `background`/`color` 쌍(`::selection` 등)을 파싱해 4.5 미만이면 실패 *(현행 `::selection`은 `ivory` on `vermillion` **4.16** → **실패해야 한다**)*
|
||||
- **C11-7 문서 동기화** — `docs/DESIGN.md` §3 색상표의 hex ↔ `globals.css`의 `--*-rgb` 대조, 불일치 시 실패 *(현행 일치 — 회귀 방어용)*
|
||||
- **C11-8 시맨틱 페어링** — R-8.5의 `vermillion = Orchestration` 대응을 히어로 강조 요소에 대해 확인. **D-12 확정 이후에만 활성화**한다
|
||||
- **C11-9 `dark:` 변형 제로** *(v1.14 신설 · R-8.29)* — `app/**`·`components/**`에 **`dark:` + 강조색**(`cobalt`·`vermillion` 및 각 `-dark`) 조합이 **1건이라도 있으면 실패**. 예외는 R-8.32 화이트리스트 등재로만 허용한다(부류 B의 `dark:text-ivory` 등) *(현행 `dark:*-cobalt` **38건** → **실패해야 한다**)*
|
||||
- [ ] AC-49 *(v1.13 신설 · AC-48 유효성)* **Gate 10·11 폴트 인젝션** — ① F1 `DirectionsBlock:8` 현행 유지 → C11-2 실패, ② F2 `lectures:50`의 `text-xs text-vermillion` 현행 유지 → AC-44 실패(**면제 제거 확인**), ③ F3 다크 `--cobalt-rgb`를 라이트와 동일하게 유지 → AC-45 실패(1.00), ④ F4 `Header:69`에서 `dark:text-ivory` 제거 → AC-43 또는 C11-3 실패(다크 2.85), ⑤ F5 `standardization:89`를 `bg-cobalt text-ivory`로 되돌림 → AC-43 실패(3.45), ⑥ F6 화이트리스트 밖 위치에 `text-white` 1건 추가 → AC-47 실패(**위치 불일치** — 단순 계수로는 통과했을 사례), ⑦ F7 `focus:ring-cobalt/50` 현행 유지 → C11-5 실패(1.80), ⑧ F8 `docs/DESIGN.md` 색상표 hex 1글자 변경 → C11-7 실패, ⑨ F9 실존하지 않는 `(파일, 행)`을 화이트리스트에 등재 → 고아 항목으로 실패, **⑩ F10 `members/page.tsx:108`을 `text-cobalt-dark dark:text-cobalt`로 되돌리면 AC-44 실패(다크 4.37) — 후방탐색 `(?<!dark:)` 제거 확인**, **⑪ F11 `dark:bg-cobalt` 1건을 되살리면 C11-9 실패** *(⑩·⑪은 v1.14 신설)*.
|
||||
- **11건 전부 실패를 재현하지 못하면 Gate 11을 채택하지 않는다.** F1·F2·F3·F7·**F10·F11**은 **현행 코드 자체가 주입 상태**이므로 게이트 구현 직후 실행만으로 즉시 검증된다
|
||||
- [ ] AC-50 *(v1.13 신설 · R-8.27 · AC-23)* **`docs/DESIGN.md` 동기화** — 아래 4건을 교정하고 구현과 일치시킨다.
|
||||
- **§10 커스터마이즈 훅(302~308행)에 대비 제약을 명문화**한다. 현행 안내는 교체 **방법**만 적고 지켜야 할 제약을 적지 않아 `eaddc4f` 사고의 직접 원인으로 규명되었는데, **v1.11에서 지적된 뒤에도 미교정 상태로 남아 같은 유형의 무기록 색 교체가 4회 더 발생**했다
|
||||
- **§8-1(282행)** 이 Header 활성 배지를 `bg-vermillion text-white dark:text-ivory`로 기술하나 실제는 `bg-cobalt-dark` — 갱신한다
|
||||
- **§9(292행)** "강조색 위 텍스트는 항상 `ivory`" ↔ 실제 `text-white` 7건 — R-8.26 v1.13 조항(`-dark` 배경은 `ivory` 단독 / DEFAULT 배경만 `white` 예외)으로 서술을 정합화한다
|
||||
- **§2(30행)** 시맨틱 페어링 ↔ 히어로 `Orchestration` 색 변경 — **D-12 확정 방향에 맞춰** 코드 또는 문서 중 한쪽을 교정한다
|
||||
- [ ] AC-51 *(v1.14 신설 · R-8.26·R-8.29)* **다크 테마 소형 텍스트 해소** — `dark:text-cobalt`가 `bg-paper` 위 소형 텍스트에 적용되어 다크 테마에서 **4.37**로 미달하는 아래 **8곳**이 `text-cobalt-dark` 단독(다크 **5.96**)으로 교정되었음을 개별 확인한다.
|
||||
- `app/page.tsx:148`(Pillar 번호, `text-sm`, `bg-paper` @`:142`) · `app/page.tsx:187`(`Pillar #`, `text-xs` @`:183`)
|
||||
- `app/members/page.tsx:108`(Ph.D. 학위, `bg-paper` @`:102`) · `app/members/page.tsx:138`(M.S. 학위, `bg-paper` @`:132`)
|
||||
- `app/_components/ProjectPageView.tsx:88`·`:93`(Standards Track / Organization, `text-xs` @`:80`, `bg-paper` @`:44`)
|
||||
- `app/standardization/page.tsx:36`(`[{scope}]`, `bg-paper` @`:32`) · `app/publications/[category]/page.tsx:124`(등록번호, `text-xs` @`:118`, `bg-paper` @`:103`)
|
||||
- `bg-ivory` 위 사용처(`ProjectPageView:53` · `members:54,63,202` · `standardization:64` · `DirectionsBlock:51` · 섹션 키커 전량)는 **4.82로 통과하되 여유가 0.32뿐**이므로 함께 `-dark` 단독으로 정리한다(→ R-8.29 · C11-9)
|
||||
|
||||
---
|
||||
|
||||
@@ -623,6 +773,12 @@ Publications(P4)는 데이터 규모(223건)와 필터·점진적 노출 요구
|
||||
| **D-06** | 본문 여백·최대 폭 컨테이너를 **어느 계층에 부여**할 것인가 | ✅ **확정** (v1.6, 2026-07-30) | — | **결정: `<main>` 일괄 부여안 기각 → 라우트 래퍼 방식 + 자동 게이트 채택.** Planner 초안(`<main>`에 `container-content`)은 1행 수정·누락 불가라는 장점이 있으나, 개발 팀장 이의제기(Job `2560771b`)대로 **full-bleed 밴드·마퀴 티커를 구조적으로 차단**한다. 래퍼 방식의 유일한 약점(신규 라우트 누락)은 **AC-25 게이트로 상쇄**되므로 래퍼 방식이 우월하다. → R-8.17~R-8.19 |
|
||||
| **D-07** | Research Project 카드의 **`Funder/Sponsor` 실데이터 확보** 여부 | 🔴 **미결** | `funder` 미노출 + 실존하는 `Standards Track` 표기 | PJ-07. `ISL-2026/` 전체를 `과제·연구비·IITP·NRF·한국연구재단·정보통신기획평가원` 등으로 검색해 **0건**이다. **임의 생성은 MG-05·R-9.17·R-9.18 위반이며 실명·실제 이메일이 게시되는 페이지이므로 절대 불가.** 기본값으로 **구현 차단은 아니며**, 지도교수가 사업명·부처·기간을 제공하면 `content/projects.ts` 재생성만으로 반영된다 |
|
||||
| **D-08** | Research Project 집합의 **정체성 범위** — 레거시 3건(IoT/CCIS/VLC)만인지, ANL 신규 과제를 포함할지 | 🟡 미결 | 레거시 3건 노출, 섹션 라벨 `Research Projects` | 레거시 3건은 IoT 시대 실적이고 현 정체성은 ANL이다. 신규 과제 추가는 지도교수 입력이 필요하다(MG-05). 레거시 3건 비노출은 PR-01에 역행하므로 배제 |
|
||||
| **D-09** | `/publications` 인덱스 라우트를 **Overview 뷰 / `redirect()` / 기본 카테고리 재렌더 / 전체 통합 뷰 + `All` 탭** 중 무엇으로 둘 것인가 | ✅ **재확정** (v1.10, 2026-07-31) | — | **결정: Overview 뷰 채택(A안).** v1.9는 C안(첫 카테고리 본문 재렌더)을 채택했으나, 개발 팀장 이의제기(Job `22b7a309`)대로 **동일 DOM 정적 HTML 2본**이 생성되어 R-8.25를 위반한다 — 지적이 타당하여 채택안을 교체한다. `redirect()`(B안)는 AC-25·AC-40과 충돌하고, `All` 탭 포함 전체 통합 뷰(D안)는 commit `22a31ff`의 탭 제거 결정을 되돌리며 223건 단일 페이지로 P-07·R-9.11과도 충돌한다. A안은 **고유 콘텐츠**(카테고리별 집계 + 최신 하이라이트)를 가지므로 중복이 원천 소멸하고, `All` 탭 없이 3개 탭 체계를 유지해 `22a31ff`의 의도를 보존한다 → IA-03 재개정 · R-8.25 · AC-41 |
|
||||
| **D-10** | `eaddc4f`가 도입한 `--cobalt: #4A7CE8`의 **라이트 테마 대비 미달**(ivory 3.49 / paper 3.16)을 어떻게 해소할 것인가 | ✅ **확정** (v1.11, 2026-07-31) | — | **결정: C안 채택 — `--cobalt`는 #4A7CE8 그대로 두고 `--cobalt-dark`를 `52 98 204`(#3462CC) → `50 84 158`(#32549E)로 재조정한 뒤, 소형 텍스트 사용처를 `-dark` 변형으로 라우팅한다.** 근거: ⓐ **사용자가 지정한 브랜드 색이 보존된다** — 히어로 SVG(`var(--cobalt)` 직접 참조)·대형 세리프 수치·불릿·테두리·아이콘 등 시각적으로 먼저 인지되는 표면은 전부 #4A7CE8을 유지하며, 이들은 대형 텍스트·비텍스트 UI 기준(3:1)을 실측 3.49로 충족한다. ⓑ `cobalt-dark` 슬롯은 **현재 사용처 0건**이므로 신규 토큰 없이 빈 슬롯을 활용해 토큰 계층이 늘지 않는다. ⓒ 결과값 6.43 / 5.82는 `vermillion-dark`(6.38 / 5.77)와 **동일 등급**이라 두 강조색이 대칭이 된다. **기각 사유** — A안(`--cobalt` 자체를 #3C64BC로 하향)은 브랜드 색이 눈에 띄게 어두워져 지시를 훼손하고, B안(신규 `--cobalt-text` 토큰)은 비어 있는 `-dark` 슬롯과 역할이 중복되며, D안(#1B3A8A 복귀)은 사용자 지시 역행이다. **#3C64BC(paper 4.51)가 아니라 #32549E(5.82)를 쓰는 이유**는 4.51이 임계선 대비 여유 0.01에 불과해 §12.1의 `opacity-90` 사고(4.69 → 4.05)처럼 후속 변경 한 번으로 재미달하기 때문이다. **v1.12 개정 — 배지·칩 전경/배경**: 개발 팀장(Agy) 이의제기(Job `7f136931`)를 수용해 v1.11의 `bg-cobalt text-ink dark:text-ivory`(5.03 / 4.82)를 **폐기**하고 **`bg-cobalt-dark text-ivory`(6.43 / 6.57)** 로 교체한다. v1.11은 소형 *텍스트* 12곳에만 `-dark` 라우팅을 적용하고 **동일 크기 등급(10.4 px)의 상태 칩 2곳은 DEFAULT 배경으로 남겨 스스로의 원칙과 어긋나 있었다** — 지적이 타당하다. 다만 이의제기의 권장 전경 `text-white dark:text-ivory`(7.25 / 6.57)는 **미채택** — `-dark` 배경에서는 `ivory` 전경만으로 양 테마가 통과하므로 팔레트 외 색 `white`와 `dark:` 변형이 **모두 불필요**하다(→ R-8.29). 아울러 권장안을 그대로 적용하면 `standardization:87-89` 삼항식의 **나란한 두 칩의 상대휘도 대비가 1.19 → 1.54로 악화**되므로, vermillion 칩도 `-dark`로 통일해 **1.01**로 맞춘다(→ R-8.26 짝 칩 등급 일치 조항) **v1.13 개정 — 확정값 갱신**: 커밋 `ca4a7ee`·`8db16e4`가 `--cobalt`를 **#4E77FF**, `--cobalt-dark`를 확정값 #32549E가 아닌 **#345AD2**(`52 90 210`)로 설정했다. #345AD2는 `paper` **4.76** / `ivory` **5.26**으로 R-8.26 바닥값을 **충족**하고 브랜드 색상(226.1°)에도 #32549E보다 가까우므로 **확정값을 #345AD2로 개정**한다. 단 `paper` 여유가 **0.26**뿐이라 위에서 #3C64BC를 배격한 것과 같은 사유가 부분적으로 적용되므로 §12.2 R-4로 등재하고, **추가 상향은 금지**한다(색상 유지 시 `paper` 4.5 한계는 #3B5AC2 4.92 부근이라 이득이 작다). 아울러 **#4E77FF는 반전 대비 1.00으로 R-8.28을 다시 위반**하며 이는 D-10 근본 원인의 재발이다 → **D-12**로 이관 → R-8.26 · R-8.28 · R-8.29 · AC-42 · AC-43 · AC-44 · AC-45 · AC-47 |
|
||||
| **D-11** | `text-vermillion`(DEFAULT) 소형 텍스트 **약 30곳**을 교정할 것인가 | 🟡 미결 | **현행 유지 (교정하지 않음)** | 실측 `ivory` 4.16 / `paper` 3.77로 R-8.26 소형 텍스트 기준에 미달하나, ⓐ `eaddc4f`가 만든 회귀가 아니라 **최초 커밋 `36a4cd8` 이래의 상태**이고, ⓑ 30곳 일괄 변경은 "전체적인 에디토리얼 디자인 시스템 유지" 목표와 정면 충돌하는 시각적 대변경이며, ⓒ **vermillion(#D9342B)은 사용자가 명시적으로 현행 유지를 지정한 색**이다. 교정 시에는 D-10과 동일한 패턴(`text-vermillion-dark dark:text-vermillion`, 라이트 6.38 / 5.77)을 적용하면 되므로 **기술적 경로는 확보되어 있고 판단만 남는다.** AC-44 화이트리스트에 사유와 함께 등재해 게이트가 이 결정을 명시적으로 기록하게 한다. **v1.13 실측 갱신** — 정확한 건수는 **23건**(상태 변형 포함)이며 최저값은 `bg-paper` 카드 내부의 `members/page.tsx:114,144` **3.77**이다. `Footer.tsx:53,71`의 `hover:text-vermillion`도 `bg-paper` 위라 **3.77**이다 |
|
||||
| **D-12** | **"Primary Color(#4E77FF) 전 요소 통일 적용"의 판정 기준을 토큰 값으로 볼 것인가, 화면 인지색으로 볼 것인가** — 그리고 히어로 `Orchestration` 강조어의 색을 R-8.5 규약대로 vermillion으로 되돌릴 것인가 | 🔴 미결 (v1.13 신설) | **A안 + B안 병행** | 접근성 제약상 소형 텍스트에는 #4E77FF를 쓸 수 없다(라이트 3.45 / 3.12). 그 결과 **토큰은 #4E77FF, 화면은 #345AD2**라는 이중 상태이며, 라이트 테마에서 #4E77FF가 **정적으로** 칠해지는 곳은 히어로 SVG와 6 px 불릿 **2곳뿐**이다(hover·focus 제외). **A안(현행 유지)**: 코드 변경 0, 지시는 토큰 수준에서 충족. **B안(대형 표면 확대)**: `page.tsx:84,92`의 30 px 지표 등 3:1을 충족하는 **대형 텍스트·도형 표면에 #4E77FF를 적극 확대**해 브랜드 가시성을 회복한다 — 접근성 손실 없이 문제를 실질적으로 완화하는 유일한 경로다. **C안(`-dark` 상향) 기각** — 여유 0.26에서 더 밝히면 D-10이 배격한 임계선 상황에 근접한다. **히어로 `Orchestration`**: `docs/DESIGN.md:30`과 R-8.5가 **vermillion = Orchestration**을 명문 규정하므로, cobalt를 유지하려면 **문서를 먼저 개정**해야 한다. 현재는 코드가 문서를 앞질러 같은 페이지 안에서 규약이 반씩 갈린 상태다. 어느 쪽이든 **코드와 문서가 같은 값을 말해야 한다** → R-8.5 · AC-48(C11-8) · AC-50 |
|
||||
| **D-13** | `--vermillion`의 **반전 대비 1.25**(라이트 `217 52 43` ↔ 다크 `235 75 66`)를 교정할 것인가 | 🟡 미결 (v1.13 신설) | **현행 유지 + 화이트리스트 등재** | R-8.28 바닥값 2.0에 미달하나 ⓐ **최초 커밋 이래의 상태**로 특정 커밋이 만든 회귀가 아니고, ⓑ **#D9342B는 사용자가 현행 유지를 지정한 Secondary**이며, ⓒ `--vermillion-dark`(2.58)가 정상이라 소형 텍스트 경로는 이미 확보되어 있다. 다만 R-8.28의 취지대로 **"반대 테마 값을 조정하는 순간 양 테마가 동시에 무너지는" 상태**이므로, AC-45 화이트리스트에 **사유와 함께 등재**해 게이트가 이 결정을 명시적으로 기록하게 한다 → R-8.28 · AC-45 |
|
||||
| **D-14** | `--cobalt` DEFAULT의 **반전 대비 1.00**을 어떻게 처리할 것인가 — R-8.28 예외로 등록할 것인가, 다크 값을 대칭 상향할 것인가 | 🔴 미결 (v1.14 신설, Agy 이의제기 `4962a667` 반영) | **A안 (브랜드 고정 예외)** | R-8.29 정리(강조색 `dark:` 변형 38건 제거) 후 `--cobalt` DEFAULT는 **어느 테마에서도 소형 텍스트를 담지 않으며** 남는 사용처(히어로 SVG·6 px 불릿·hover 테두리·토글 아이콘·대형 텍스트)가 **양 테마에서 3:1을 충족**한다(최저 3.12). 즉 **측정 가능한 실패는 남지 않고 형식적 위반만 남는다.** **A안 — 브랜드 고정(theme-invariant) 토큰으로 등록**: 다크 값을 라이트와 정확히 동일(`78 119 255`)하게 맞춰 불변성을 소스에 드러내고 AC-45 화이트리스트에 등재한다. 근거 ⓐ R-8.28은 *수단*, R-8.26·R-8.30(테마별 대비)이 *목적* 이며 목적 규칙이 양 테마에서 실효 검사되면(AC-43 v1.14 개정) 안전망이 유지된다 — 검증: `#1B3A8A` 역주입 시 다크 지면 대비 **1.80**으로 AC-43 즉시 실패. ⓑ #4E77FF는 **사용자 지정 Primary**이므로 다크 테마만 다른 색으로 치환하면 브랜드 정체성이 갈라진다. **B안 — 대칭 상향(이의제기 구조 채택 · 수치 정정)**: `--cobalt-rgb`(다크) `168 188 255`(#A8BCFF, 반전 **2.09**) + `--cobalt-dark-rgb`(다크) `199 212 255`(#C7D4FF, 반전 **4.04**, 다크 `paper` 11.56 > DEFAULT 9.14로 R-8.33 충족). 이의제기 원안의 `#85ACFF`는 반전 **1.73**으로 **R-8.28 미달**이라 미채택하고, `#B0CDFF`의 기재 반전 2.50은 `#345AD2`의 상대휘도를 폐기값 #32549E의 0.0949로 적은 오기에서 파생된 값으로 실제는 **3.68**이다. B안의 비용은 다크 강조색이 **파스텔로 변해 브랜드 색이 다크 테마에서 사라지고** `bg-cobalt-dark` 칩이 대비 12.75의 거의 흰 배경이 되는 것 — **D-12(브랜드 가시성)를 다크 테마에서 악화**시킨다. **A안은 본 계획자가 작성한 R-8.28을 스스로 완화하는 제안이므로 cline·GM의 별도 심사를 요청**하며, 승인 전에는 집행하지 않는다 → R-8.28 · R-8.29 · R-8.33 · AC-43 · AC-45 |
|
||||
|
||||
#### D-01 상세
|
||||
|
||||
@@ -658,6 +814,178 @@ PR-04에 명시된 4개 라벨 중 **1번과 2번의 범위가 논리적으로
|
||||
|
||||
이 확정으로 §4.1 사이트맵·§7 `PubCategory`·P-01·P-03이 v1.5에서 함께 갱신되었고, 문서 상태가 `승인(Approved)`으로 전환되었다.
|
||||
|
||||
#### D-09 상세 — 색 토큰 알파 계약 붕괴와 `/publications` 리다이렉트 *(v1.9)*
|
||||
|
||||
D-09를 촉발한 것은 라우트 문제 하나가 아니라, **정적 게이트 전부를 통과하면서 화면만 무너지는 결함 계열**이 한 배치에서 두 종류 확인된 사건이다. 계약을 §8.7로 승격한 근거를 여기에 기록한다.
|
||||
|
||||
**(1) 근본 원인 — 쉼표 구분 `--*-rgb`**
|
||||
|
||||
`app/globals.css`는 토큰을 `--vermillion-rgb: 217, 52, 43;`처럼 **쉼표 구분**으로 정의해 왔다. 반면 `tailwind.config.ts`는 `rgb(var(--vermillion-rgb) / <alpha-value>)`를 선언했고, Tailwind는 이를 다음으로 전개한다.
|
||||
|
||||
```
|
||||
.bg-vermillion { --tw-bg-opacity: 1; background-color: rgb(var(--vermillion-rgb) / var(--tw-bg-opacity, 1)) }
|
||||
↓ var() 치환 후
|
||||
rgb(217, 52, 43 / 1) ← 무효
|
||||
```
|
||||
|
||||
`rgb()`는 **공백+슬래시(현대)** 또는 **전량 쉼표(레거시)** 중 하나여야 하며 혼용은 문법 위반이다. 무효 선언은 *invalid at computed-value time* 으로 폐기되므로 **토큰 배경색이 아예 적용되지 않는다.** 커밋 `7889398`("solid vermillion background")·`40b252b`("enforce high contrast white text")가 같은 증상을 반복 교정하려 한 이력이 이 진단을 뒷받침한다.
|
||||
|
||||
**(2) 부작용 — `<alpha-value>` 제거가 만든 2차 무성 결함**
|
||||
|
||||
`7889398`은 위 증상을 `colors.ivory = "var(--ivory)"` 형태로 축약해 해결했다. 단색은 정상화되었으나, **알파 자리표시자가 사라지면 Tailwind는 슬래시 수식자 유틸리티를 오류 없이 생략한다**(독립 프로브로 확인). 그 결과 소스에 남은 **26개 사용처가 전부 무효화**되었고 lint·tsc·빌드·Gate 0–7은 그대로 통과했다.
|
||||
|
||||
| 소멸한 유틸리티 계열 | 대표 사용처 | 실제 화면 |
|
||||
| :--- | :--- | :--- |
|
||||
| `bg-*/…` | `Header` `bg-ivory/85` | sticky 헤더 **배경 소실**(본문이 비쳐 지나감) |
|
||||
| `border-*/…` (15건) | `border-line/60` 등 | Preflight 기본색 **`#e5e7eb`** — 팔레트 외 회색, 다크 모드에서 특히 부각 |
|
||||
| `text-*/…` | `Footer` `text-ink/90` | 부모 색 **상속** |
|
||||
| `ring-*/…` | `ProjectPageView` `focus:ring-vermillion/50` | Tailwind 기본 **파란색** 포커스 링 |
|
||||
|
||||
**(3) 3차 결함 — 인라인 `style` 우회**
|
||||
|
||||
단색이 적용되지 않는 상태를 우회하려 활성 배지에 `style={{ backgroundColor: "var(--vermillion)", color: "#ffffff" }}`가 투입되었다. 인라인 `style`은 `dark:` 변형을 항상 이기므로 **다크 모드에서 흰색 전경이 고정**되었고, `opacity-90`이 함께 도입되어 대비가 추가로 떨어졌다. 이는 Job `b3f001de`에서 제기하고 `43c75da`에서 해소했던 R-9.6 결함의 **직접 회귀**다.
|
||||
|
||||
| 상태 | 라이트 | 다크 | 판정 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 배지 라벨 (흰색 단색) | 4.69:1 | **3.74:1** | 다크 미달 |
|
||||
| 배지 번호·`en` (`opacity-90`) | **4.05:1** | **3.29:1** | 양 테마 미달 |
|
||||
| 참고: `43c75da`의 `dark:text-ivory` | 4.69:1 | 5.01:1 | 양 테마 충족 |
|
||||
|
||||
**(4) 확정 — 계약 복원이 스타일 보존과 일치한다**
|
||||
|
||||
| 항목 | 내용 |
|
||||
| :--- | :--- |
|
||||
| 채택 | `--*-rgb`를 **공백 구분으로 정정**(R-8.21) + `tailwind.config.ts`에 **`<alpha-value>` 복원**(R-8.22) + 인라인 `style`·`opacity-*` **제거**(R-8.23) |
|
||||
| 기각 | `<alpha-value>` 제거 상태 유지 후 26개 사용처를 각각 리터럴 색으로 치환 — 토큰 체계가 사실상 붕괴하고 다크 모드 반전이 사용처마다 깨진다 |
|
||||
| 스타일 영향 | **의도된 디자인으로의 복원이며 재디자인이 아니다.** 사용처 26곳은 원래 의도대로 렌더되고, 단색 토큰은 `7889398` 이후 상태를 그대로 유지한다 |
|
||||
| 재발 방지 | AC-35(슬래시 수식자 실효성) · AC-36(공백 구분) · AC-37(인라인 `style` 0건) · AC-38(양 테마 대비) 게이트 신설 |
|
||||
|
||||
**(5) `/publications` 라우트** *(v1.10 재확정)*
|
||||
|
||||
같은 배치에서 `All Overview` 탭 제거와 함께 `/publications`가 `redirect()`로 대체되어 **Gate 8이 실측 실패**했다(`<main> tag not found in .next/server/app/publications.html`).
|
||||
|
||||
v1.9는 **C안(첫 카테고리 본문 재렌더)** 을 채택했으나, 개발 팀장 이의제기(Job `22b7a309`)가 **정적 HTML 2본이 동일 DOM을 갖는 문제**를 지적했고 이는 타당하다. 현재 코드베이스에 `metadataBase`·`alternates.canonical`이 **전무**함을 확인했으므로(grep 0건), C안을 그대로 두면 완화 장치 없이 중복이 노출된다. 이에 **A안(Overview 뷰)** 으로 교체한다.
|
||||
|
||||
| 안 | 중복 | `22a31ff`(All 탭 제거) 의도 | AC-25/40 | 판정 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| A · Overview 뷰 | **없음** | **보존**(탭은 3개 유지) | 충족 | ✅ **채택** |
|
||||
| B · `redirect()` 유지 | — | 보존 | **위반** | 기각 |
|
||||
| C · 첫 카테고리 재렌더 | **2본 동일** | 보존 | 충족 | 기각 (v1.9 → v1.10 교체) |
|
||||
| D · 전체 통합 + `All` 탭 | 없음 | **역행** | 충족 | 기각 |
|
||||
|
||||
한편 이의제기의 두 번째 논거(`isActive` 하이라이트 모호)는 **성립하지 않는다.** `Header.tsx`의 활성 판정은 `pathname === href || pathname.startsWith(\`${href}/\`)`이므로, `href="/publications"`는 세 하위 카테고리 경로 전부에 대해 이미 참이 된다. v1.9 계획의 3-3(예외 분기 제거)은 이 일반 규칙만으로 성립하며 별도 조치가 필요 없다. Overview에서 하위 탭이 하나도 활성이 아닌 것은 결함이 아니라 **Overview의 정상 상태**다(→ IA-03).
|
||||
|
||||
---
|
||||
|
||||
#### D-10 상세 — 강조색 교체가 대비를 붕괴시킨 경위 *(v1.11)*
|
||||
|
||||
**증상.** 커밋 `eaddc4f`("update primary color token to #4A7CE8 and secondary to #D9342B") 이후 라이트 테마에서 cobalt 계열 소형 텍스트가 읽기 어려워졌다. **lint·tsc·프로덕션 빌드·기존 게이트 0~9가 전부 통과한다** — 빌드는 exit 0, 정적 13/13 생성, 동적 라우트 0건이다.
|
||||
|
||||
**변경 실체.** `:root` 블록 2줄만 바뀌었다.
|
||||
|
||||
```diff
|
||||
- --cobalt-rgb: 27 58 138; /* #1B3A8A */
|
||||
- --cobalt-dark-rgb: 18 40 99; /* #122863 */
|
||||
+ --cobalt-rgb: 74 124 232; /* #4A7CE8 */
|
||||
+ --cobalt-dark-rgb: 52 98 204; /* #3462CC */
|
||||
```
|
||||
|
||||
`html.dark` 블록(`--cobalt-rgb: 77 119 255`)과 `--vermillion-*`는 **양 블록 모두 미변경**이다.
|
||||
|
||||
**대비 실측(WCAG 2.1 공식 재계산).**
|
||||
|
||||
| 조합 | 변경 전 | 변경 후 | 판정 |
|
||||
| :--- | ---: | ---: | :--- |
|
||||
| `text-cobalt` on `ivory` | 9.23 | **3.49** | 소형 텍스트 미달 |
|
||||
| `text-cobalt` on `paper` | 8.35 | **3.16** | 소형 텍스트 미달 |
|
||||
| `ivory` on `bg-cobalt` | 9.23 | **3.49** | 미달 |
|
||||
| `text-cobalt-dark` on `paper` | 11.16 | **4.48** | 간발의 미달 |
|
||||
|
||||
소형 텍스트 미달 사용처는 **14건**(`text-cobalt` 12건 + `bg-cobalt` 소형 배지 2건)이다. 반면 `page.tsx:92`(`text-3xl`, 30 px)·`page.tsx:174`(불릿 도형)·`ThemeToggle.tsx:146`(아이콘)·`members/page.tsx:102,132`(`hover:border-cobalt`)는 **대형 텍스트·비텍스트 UI 기준 3:1을 3.49로 충족**한다. 즉 **#4A7CE8은 큰 글자·도형으로는 적법하고 작은 글자로만 부적법**하며, 이 경계가 D-10 채택안의 설계 근거다.
|
||||
|
||||
**근본 원인 — 반전 계약 이탈.** 이 시스템의 모든 토큰은 `html.dark`에서 반전된다(`ivory` 245→18, `ink` 10→245). cobalt도 예외가 아니어서 라이트는 짙은 남색, 다크는 밝은 청색이었다.
|
||||
|
||||
| | 라이트 L | 다크 L | 라이트↔다크 대비 |
|
||||
| :--- | ---: | ---: | ---: |
|
||||
| 변경 전 | 0.0509 (#1B3A8A) | 0.2199 (#4D77FF) | **3.13** |
|
||||
| 변경 후 | 0.2170 (#4A7CE8) | 0.2199 (#4D77FF) | **1.01** |
|
||||
|
||||
**라이트 값이 다크 값과 사실상 같은 밝기가 되었다** — cobalt 한 토큰만 반전 체계에서 이탈해, 라이트 테마가 다크 테마용 밝기를 그대로 반입했다. → R-8.28.
|
||||
|
||||
**게이트가 놓친 이유.** Gate 6은 `["--ink", "--ivory", …, "--cobalt"]`의 **문자열 존재 여부만** 확인하고, Gate 9는 **알파 계약**(구분자·`<alpha-value>`·인라인 `style`)만 확인한다. `run_e2e_tests.py`에 `contrast`·`4.5`·`luminance` 검사는 **0건**이다. 즉 **토큰의 "값"을 검사하는 게이트가 없어** `--cobalt: #FF00FF`로 바꿔도 10개 게이트가 전부 통과한다. → AC-43 · AC-44 · AC-45 · AC-46.
|
||||
|
||||
**문서가 사고에 기여한 부분.** `docs/DESIGN.md`의 커스터마이즈 훅은 "강조색 2개와 그 `-dark` 변형만 교체하면 됩니다"라고 **교체 방법만** 안내하고 **지켜야 할 대비 제약을 적지 않는다.** 안내를 문자 그대로 따른 결과가 이 커밋이므로, 문서 교정(R-8.27)이 재발 방지의 일부다.
|
||||
|
||||
**부수 확인 — Secondary는 무변경.** 커밋 메시지의 `…and secondary to #D9342B`는 변경이 아니라 **현상 유지의 서술**이다. `--vermillion-rgb`는 최초 커밋 `36a4cd8`(`--vermillion: #d9342b`) 이래 상수이며, `02247a0`의 `217, 52, 43` → `217 52 43`은 R-8.21 구분자 교정이지 색 변경이 아니다. 수렴 대장에 "Secondary 변경 검증 완료"로 기재되면 사실과 어긋나므로 **무변경(no-op)** 으로 기록한다.
|
||||
|
||||
**AC-42 미충족 잔존.** 직전 수렴(`02247a0`)에서 AC-42는 통과로 판정되었으나, 문언의 "vermillion 배경 위 텍스트 **전량**" 중 교정된 것은 Header 2곳·`CategoryNav` 1곳뿐이고 `standardization/page.tsx:40`·`:88`, `lectures/page.tsx:89`, `members/page.tsx:177`의 **4곳이 4.16:1로 남아 있다**(뒤 두 곳은 `group-open:` 상태 변형이라 육안 리뷰에서 누락되기 쉽다). → AC-42 개정.
|
||||
|
||||
##### D-10 보론 — 배지·칩 부류 분리 *(v1.12, Agy 이의제기 `7f136931` 반영)*
|
||||
|
||||
**v1.11의 설계 오류.** v1.11 §6.5는 `bg-cobalt`(#4A7CE8) 배경을 유지한 채 전경만 `text-ink`로 바꾸도록 정했다. 이는 채택안 C의 원칙("**작은 요소는 `-dark` 변형으로 라우팅한다**")을 **소형 텍스트 12곳에만 적용하고 동일 크기 등급의 상태 칩 2곳에는 적용하지 않은** 일관성 결함이다. 개발 팀장 이의제기가 이를 정확히 지적했다.
|
||||
|
||||
**기술적 근거(심미 판단과 독립).** 이의제기의 논거는 "짙은 검은 텍스트가 매거진 톤을 훼손한다"는 심미 판단이나, 같은 결론을 취향과 무관하게 지지하는 근거가 있다.
|
||||
|
||||
| `bg-cobalt`(#4A7CE8) 위 전경 | 대비 |
|
||||
| :--- | ---: |
|
||||
| `ivory` | 3.49 🔴 |
|
||||
| `white` | 3.93 🔴 |
|
||||
| `ink-soft` | 3.94 🔴 |
|
||||
| `ink` | **5.03** ✅ (유일) |
|
||||
|
||||
팔레트 내 후보 4종 중 **통과는 1종뿐**이고, 그마저 라이트 전용이다 — 다크에서 `--ink-rgb`가 `245 241 232`로 반전되어 3.49로 미달하므로 `dark:text-ivory`를 수동으로 덧대야 한다. 즉 **반전 계약을 손으로 우회하는 구조**였다. `bg-cobalt-dark text-ivory`는 6.43 / 6.57로 **`dark:` 없이 양 테마가 자동 통과**한다(→ R-8.29).
|
||||
|
||||
**권장안을 그대로 쓰지 않은 이유 — 짝 칩 무게 불균형.** 대상인 `app/standardization/page.tsx:86-90`은 **하나의 삼항식**으로, `InProgress`(vermillion)와 그 외(cobalt) 배지가 문서 목록의 **같은 자리에 번갈아** 나타난다.
|
||||
|
||||
| 조합 | 배경 상대휘도 | 두 칩 간 대비 |
|
||||
| :--- | ---: | ---: |
|
||||
| v1.11: `vermillion` #D9342B ↔ `cobalt` #4A7CE8 | 0.1738 ↔ 0.2170 | 1.19 |
|
||||
| 이의제기 권장안: `vermillion` ↔ `cobalt-dark` #32549E | 0.1738 ↔ 0.0949 | **1.54** (악화) |
|
||||
| **v1.12 확정**: `vermillion-dark` #A8231C ↔ `cobalt-dark` #32549E | 0.0961 ↔ 0.0949 | **1.01** |
|
||||
|
||||
cobalt 칩만 어둡게 내리면 vermillion 칩이 밝은 채로 남아 무게 차가 오히려 벌어진다. **이의제기의 논거(에디토리얼 완성도)가 요구하는 vermillion 쪽 대칭 조치가 권장안에서 누락**되었다. 두 강조색의 `-dark` 변형은 휘도가 거의 같으므로(0.0961 vs 0.0949) **추가 색 조정 없이** 균형이 나온다.
|
||||
|
||||
**부류 A / 부류 B.** 크기 기반으로 두 패턴을 분리한다 — R-8.26의 논리(가장 작은 글자에 가장 여유 있는 대비)를 연장한 것이다.
|
||||
|
||||
| 부류 | 대상 | 패턴 | 라이트 / 다크 |
|
||||
| :--- | :--- | :--- | ---: |
|
||||
| **A** 상태 칩(`text-[0.65rem]`) | `standardization:40`·`:88`(verm) · `standardization:89` · `publications/[category]:135`(cobalt) | `bg-{accent}-dark text-ivory` | **6.38~6.43 / 6.57~6.72** |
|
||||
| **B** 인터랙티브 내비·토글 | `Header:69,129` · `CategoryNav:22`(교정 완료) · `lectures:89` · `members:177` | `text-white dark:text-ivory` | 4.69 / 5.01 |
|
||||
|
||||
**부류 B를 `-dark`로 내리지 않는 이유**: ① Header 활성 배지는 사이트에서 가장 눈에 띄는 브랜드 표면이며 #D9342B → #A8231C 변경은 "전체 스타일 유지" 지시를 벗어난다. ② 해당 2곳은 `02247a0`에서 교정되어 리뷰 PASS를 받았으므로 불필요한 재작업·재검증을 유발한다. ③ 4.69:1은 AA를 충족한다 — 개선 여지는 있으나 결함이 아니다.
|
||||
|
||||
**시각 변경 고지.** 부류 A의 vermillion 칩 2곳은 배경이 **#D9342B → #A8231C로 실제로 어두워진다.** `#A8231C`는 팔레트에 이미 정의된 `vermillion-dark`이므로 신규 색이 아니며(R-8.1 위반 아님) 대상 면적도 `text-[0.65rem]` 칩 2개로 극히 작다. **GM이 배경 무변경을 선호하면 부류 B 패턴(`bg-vermillion text-white dark:text-ivory`, 4.69)으로 내려도 규격은 충족**되며, 그 경우 위 표의 무게 불균형 1.54가 남는다는 점만 함께 기록한다 — **차단 사유가 아니라 선택지다.**
|
||||
|
||||
#### D-12 상세 — 게이트가 녹색인 채로 남은 결함과 브랜드 색의 가시성 *(v1.13)*
|
||||
|
||||
**관측.** `eaddc4f` → `082d71e` 4개 커밋 이후 상태에서 `npm run build`(exit 0 · 13/13 정적 · `ƒ Dynamic` 0건)와 `run_e2e_tests.py` **Gate 0~10 전량**이 통과한다. 그러나 같은 상태에서 **WCAG AA 미달 6건과 통일성 결함 9건**이 실재한다. 특히 `lectures/page.tsx:89`는 **v1.12 AC-42 부류 B에 "미교정"으로 명시 등재된 항목**인데 신설된 대비 게이트가 이를 통과시켰다.
|
||||
|
||||
**원인 — 게이트의 범위 결함 3종.**
|
||||
|
||||
| ID | 결함 | 결과 |
|
||||
| :--- | :--- | :--- |
|
||||
| G-1 | AC-44 구현의 면제 조항 `if … or "text-vermillion" in line: continue`. 위반 판정 정규식이 `text-(cobalt\|vermillion)`이므로 **vermillion으로 매치된 라인은 예외 없이 그 문자열을 포함**한다 | **AC-44의 vermillion 절반이 영구 무효.** 실제 소스 4개 라인 + 주입 2건을 동일 로직에 투입해 전부 면제됨을 재현 |
|
||||
| G-2 | AC-45 구현이 `cobalt-dark`·`vermillion-dark` **2종만** 검사 | **`--cobalt` 반전 대비 1.00 미검출** — D-10 근본 원인의 재발 |
|
||||
| G-3 | AC-43 구현이 토큰 값만 보고 **소스의 전경/배경 쌍은 보지 않음** | `bg-cobalt-dark text-white`(다크 2.85)를 작성해도 통과 |
|
||||
|
||||
G-1은 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다 — 단정·면제가 구조적으로 발화하지 않는데 출력은 ✅로 찍힌다. 개별 수정이 아니라 형식 차원의 금지(**R-8.32**)로 대응한다. 아울러 §12.1 D-09(알파 계약)·D-10(반전 계약)에 이어 `focus:ring-cobalt/50` **1.80** · `hover:border-cobalt/60` **1.95**가 드러났다 — **알파·토큰 조합이 무성으로 대비를 무너뜨리는 유형의 세 번째 발현**이며 **R-8.30**의 근거다.
|
||||
|
||||
**브랜드 색의 가시성.** 라이트 테마에서 지정 Primary **#4E77FF가 정적으로 칠해지는 표면은 히어로 SVG와 6 px 불릿 2곳뿐**이다. 나머지는 전부 hover·focus 상태이거나 `-dark` **#345AD2**다.
|
||||
|
||||
| 표면 | 색 | 상태 |
|
||||
| :--- | :--- | :--- |
|
||||
| 히어로 `HeroComposition` SVG (`var(--cobalt)`) | **#4E77FF** | 정적 |
|
||||
| Pillar 불릿 `page.tsx:174` `bg-cobalt` (6 px) | **#4E77FF** | 정적 |
|
||||
| `members:102,132` `hover:border-cobalt` · `ProjectPageView:35,44` | #4E77FF | hover/focus 전용 |
|
||||
| `ThemeToggle:146` `text-cobalt` | #4E77FF | **다크 테마 전용 렌더** |
|
||||
| 섹션 괘선·키커·링크·지표·Pillar 라벨(20여 곳) | **#345AD2** | 정적 — 사용자가 "Primary"로 인지하는 색 |
|
||||
|
||||
즉 **지시는 토큰 수준에서 충족되었으나 화면 수준에서는 다른 색이 Primary로 읽힌다.** 접근성을 지키면서 이 간극을 좁히는 유일한 경로가 **B안(대형 표면 확대)** 이다 — `page.tsx:84,92`의 30 px 지표처럼 **3:1만 요구되는 표면**은 #4E77FF로 칠해도 적법하다(실측 3.45). 반대로 `-dark`를 브랜드 색 쪽으로 밝히는 C안은 `paper` 여유가 0.26뿐이라 **D-10이 #3C64BC를 배격한 것과 같은 사유**로 기각한다.
|
||||
|
||||
**시맨틱 규약의 분열.** `docs/DESIGN.md:30`과 R-8.5는 **vermillion = Agent Eng / Orchestration**을 규정한다. `2441f35`가 히어로의 `Orchestration` 강조어를 cobalt로 바꾸었으나, 같은 파일의 PILLARS 교차 배정(`page.tsx:12~48`)은 규약을 유지 중이다 — **한 페이지 안에서 규약이 둘로 갈렸다.** 코드를 되돌리든 문서를 개정하든 무방하나, **둘 중 하나는 반드시 상대에 맞춰야** AC-23이 성립한다.
|
||||
|
||||
---
|
||||
|
||||
### 12.2 리스크
|
||||
|
||||
| ID | 리스크 | 영향 | 완화 방안 |
|
||||
@@ -671,6 +999,10 @@ PR-04에 명시된 4개 라벨 중 **1번과 2번의 범위가 논리적으로
|
||||
| R-05 | 잔재 검사(MG-08)가 논문 제목의 도메인 용어까지 오검출 | **실데이터를 잔재로 오인해 삭제** → PR-01 위반 | MG-08 예외 규정을 검사 스크립트에 반영. 검사 범위를 정체성 서술 영역으로 한정 |
|
||||
| R-06 | 문서-코드 불일치 재발 | 후속 작업자 혼선 | R-9.19. 미완 영역은 문서에 미완으로 표기 |
|
||||
| R-07 | P2(Header 수정)와 P3~P5 병렬 진행 시 충돌 | 머지 충돌 | §11.1 주석대로 P2 선행 완료 |
|
||||
| R-10 *(v1.13 신설)* | 색값 교체가 **대비 재산정표 없이** 5회 연속 진행됨(`eaddc4f`·`ca4a7ee`·`8db16e4`·`2441f35`·`082d71e` 전부 커밋 본문 없음) | 관행화 시 무성 실패가 반복 발생 | R-8.1·R-8.27 이행 강제 + **AC-50**으로 `docs/DESIGN.md` 커스터마이즈 훅 교정. 문서가 원인으로 규명된 지 2라운드째 미교정 |
|
||||
| R-11 *(v1.13 신설)* | **게이트 통과가 결함 부재의 근거로 오용될 위험** — Gate 0~10 전량 ✅인 상태에서 AA 미달 6건이 실재했다 | 잘못된 PASS 확정 | 폴트 인젝션 없는 게이트는 채택하지 않는다(**AC-46 · AC-49**). 신설 게이트는 **현행 코드 자체가 주입 상태**인 항목(F1·F2·F3·F7)으로 즉시 실효성을 확인한다 |
|
||||
| R-12 *(v1.13 신설)* | `--cobalt-dark` #345AD2의 `paper` 여유가 **0.26**뿐 | 후속 변경 1회로 재미달 | 추가 상향 금지(D-10 v1.13 개정). 변경 시 R-8.27 재산정 필수 |
|
||||
| R-13 *(v1.13 신설)* | Phase 3 색 통일 작업이 "에디토리얼 디자인 시스템 유지" 목표와 충돌할 소지 | 시각적 대변경 | 단일화 대상을 **동일 역할 시그니처 내부**로 한정(R-8.31). 색 선택 자체는 **D-12**로 이관 |
|
||||
|
||||
---
|
||||
|
||||
@@ -714,3 +1046,12 @@ PR-04에 명시된 4개 라벨 중 **1번과 2번의 범위가 논리적으로
|
||||
| 1.6 | 2026-07-30 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `2560771b`) 반영. **§8.6 레이아웃 컨테이너 아키텍처·브레이크포인트 토큰 신설**(R-8.17~R-8.20) — `<main>` 일괄 부여안을 기각하고 3계층 분리(전체 폭 `<main>` / 라우트 래퍼 `container-content` / full-bleed 밴드)를 채택, `w-screen` 탈출 기법 금지(R-8.18) 및 `desktop: 1240px` 스크린 토큰화(R-8.20, Tailwind 정렬 동작 실측 확인). **D-06 신설·확정**, **AC-25·AC-26 신설**, R-9.14 검수 뷰포트 6종으로 확대. AC-21 정적 HTML 4개 → 3개, R-8.15 표준 문서 55 → 44건 실측치 정정. Job `019b7331` |
|
||||
| 1.7 | 2026-07-30 | claude (planner) | Home Hero → Research Project PageView 전환 계획 반영. **§6.1.2 신설**(PJ-01~PJ-14) — 레거시 실재 프로젝트 3건(IoT Standards·CCIS·VLC-IoT) 확인, `period`·`deliverables`를 `content/standards.ts` 조인으로 도출하는 무창작 설계, 활성 슬라이드 전용 캐러셀 금지(PJ-08)·URL 상태 동기화 금지(PJ-09)·캐러셀 라이브러리 금지(PJ-10)·scroll-snap 기반 JS-off 동작 보장(PJ-11) 규정. **H-02 개정**(슬로건 교체 허용 + H-03·H-04·H-05·H-06·H-07·H-10 존속 명시). **AC-27~AC-33 신설**, §7 `ResearchProject`·`ProjectDeliverable` 타입 추가, **D-07·D-08 신설**. Job `6b3840a1` |
|
||||
| 1.8 | 2026-07-30 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `2299a6b9`) 반영. **PJ-13 개정** — 데스크톱 3카드를 flex 스크롤 트랙에서 **`grid-cols-3` 정적 3열 + `overflow-visible`** 로 전환하고 데스크톱 퍼센트 연산을 제거. **PJ-15·PJ-16 신설**(`min-w-0`+줄바꿈으로 `shrink-0` min-content 넘침 방어 / 스크롤바 은폐 시 AC-34 동반 의무), **AC-34 신설**(데스크톱 `scrollWidth === clientWidth` 실측 판정). 이의제기의 근거 수식은 정정 — `calc(33.333%-16px)`은 초과가 아니라 **0.0125 px 미달**이며, 대안 B의 분수식은 여유가 0이어서 해당 실패 모드에 오히려 더 취약하다. Tailwind가 `calc` 연산자 공백을 자동 삽입함과 `grid-cols-3`의 `minmax(0,1fr)` 전개·미디어 쿼리 출력 순서를 실측 확인. Job `294570ab` |
|
||||
| 1.9 | 2026-07-31 | claude (planner) | 스타일 관련 누적 커밋 23건(`43c75da..b7f2e05`) 검증 결과 반영. **§8.7 색 토큰 알파 계약 신설**(R-8.21~R-8.24) — 쉼표 구분 `--*-rgb`가 `rgb(a, b, c / α)` 무효 선언을 만들어 토큰 색이 적용되지 않던 근본 원인을 규명하고, 그 우회로 도입된 `<alpha-value>` 제거가 **슬래시 수식자 유틸리티 26건을 무성 소멸**시킴을 독립 프로브로 실측 확인. 인라인 `style`·`opacity-*` 토큰 우회 금지(R-8.23)와 헤더 가로 예산(R-8.24, 1240 px 기준 −69.3 px 초과 산정) 규정. **D-09 신설·확정**(`/publications` `redirect()` 기각 → 기본 카테고리 화면), **IA-03 개정**, **AC-35~AC-40 신설**. Job `9a79e302` |
|
||||
| 1.10 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `22b7a309`) 반영. **D-09 재확정** — v1.9의 C안(첫 카테고리 본문 재렌더)이 **동일 DOM 정적 HTML 2본**을 만든다는 지적이 타당하여 **A안(Overview 뷰)** 으로 교체하고, `All` 탭 재도입(D안)은 commit `22a31ff` 결정 역행·P-07 충돌로 기각. **R-8.25 신설**(라우트 간 콘텐츠 중복 금지 + canonical 예외 절차), **AC-41 신설**(`<main>` 정규화 해시 중복 0건), **IA-03 재개정**. 이의제기의 `isActive` 논거는 정정 — `pathname.startsWith(\`${href}/\`)` 일반 규칙이 하위 카테고리를 이미 포괄하므로 모호성이 없음을 코드로 확인. 아울러 별건으로 `CategoryNav` 활성 탭(`bg-vermillion text-ivory`)이 **라이트 테마 4.16:1로 R-9.6 미달**임을 발견해 **AC-42 신설**. Job `6a6094c6` |
|
||||
| 1.11 | 2026-07-31 | claude (planner) | 강조색 교체 커밋 `eaddc4f` 검증 및 리뷰 수렴. **라이트 테마 cobalt 대비가 9.23:1 → 3.49:1(ivory) / 8.35 → 3.16(paper)로 붕괴**하여 소형 텍스트 **14개 사용처**가 AA 미달임을 WCAG 공식 재계산으로 확정. 근본 원인은 **명암 반전 계약 이탈**(라이트↔다크 상대휘도 대비 3.13 → **1.01**)이며, `run_e2e_tests.py`에 대비 검사가 **0건**이라 게이트 0~9와 빌드가 전부 통과한 채로 통과했다 — §12.1 알파 계약 사고와 동일한 무성 실패 유형의 **두 번째 발현**. **R-8.26~R-8.28 신설**(강조색 대비 바닥값 · 색값 변경 시 재측정 의무 · 명암 반전 계약 ≥2.0), **AC-43~AC-46 신설**(대비 게이트 Gate 10 · 소형 텍스트 라우팅 · 반전 계약 · 폴트 인젝션 4종). **D-10 확정** — `--cobalt`는 사용자 지정 #4A7CE8을 **유지**하고 `--cobalt-dark`만 #3462CC → **#32549E**(paper 4.48 → 5.82)로 재조정해 소형 텍스트를 `-dark`로 라우팅(A·B·D안 기각). **D-11 신설**(🟡 `text-vermillion` 소형 텍스트 30여 곳, 기본값 현행 유지). **AC-42 개정** — 직전 수렴에서 통과 판정되었으나 `standardization:40,88`·`lectures:89`·`members:177` **4곳이 4.16:1로 미교정** 잔존함을 확인하고 대상을 명시 목록화, `text-white` on vermillion 수치를 4.58 → **4.69**로 정정. §8.1 색상 토큰표를 실제 값과 동기화하고, **Secondary #D9342B는 최초 커밋 이래 무변경(no-op)** 임을 계보로 확인해 기록. Job `5c2137c6` |
|
||||
| 1.12 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `7f136931`) 반영. **D-10 배지·칩 결정 재확정** — v1.11의 `bg-cobalt text-ink dark:text-ivory`(5.03 / 4.82)는 채택안 C의 원칙(작은 요소는 `-dark` 라우팅)을 **소형 텍스트 12곳에만 적용하고 동일 크기 등급의 상태 칩 2곳에는 적용하지 않은 일관성 결함**이었음을 인정하고 폐기, **`bg-{accent}-dark text-ivory`** 로 교체(6.38~6.43 / 6.57~6.72). 이의제기의 권장 전경 `text-white dark:text-ivory`(7.25)는 **미채택** — `-dark` 배경에서는 `ivory`만으로 양 테마가 통과해 **팔레트 외 색 `white`와 `dark:` 변형이 모두 불필요**하다(**R-8.29 신설**). 아울러 권장안을 그대로 적용하면 `standardization:87-89` 삼항식의 나란한 두 칩 상대휘도 대비가 **1.19 → 1.54로 악화**됨을 실측해, vermillion 칩도 `-dark`로 통일하여 **1.01** 균형을 달성(**R-8.26에 배지·칩 라우팅 및 짝 칩 등급 일치 조항 추가**). 배지류를 **부류 A(`text-[0.65rem]` 상태 칩 4곳) / 부류 B(인터랙티브 내비·토글 5곳)** 로 분리해 **AC-42 개정**, `-dark`가 **배경**으로 쓰이는 조합을 **AC-43 검사 대상에 추가**(v1.11 목록은 전경만 다루어 칩 배경을 놓쳤다), **AC-47 신설**(팔레트 외 색 상한 8건 화이트리스트), **AC-46에 폴트 인젝션 ⑤·⑥ 추가**(폐기된 v1.11 칩 패턴 및 `white` 증가 검출). 부류 A vermillion 칩 2곳의 배경이 #D9342B → #A8231C로 어두워지는 **시각 변경은 GM 승인 사항으로 명시**하되 차단 사유가 아님을 기록. Job `3d3b823a` |
|
||||
| 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,98 @@
|
||||
`body > main > div > section.space-y-3 > div > span.font-mono.text-xs.tracking-widest.uppercase.text-cobalt-dark.font-bold`
|
||||
- 삭제
|
||||
|
||||
`body > main > div > section.space-y-3 > h1`
|
||||
- 연구실 구성원 -> "LAB MEMBERS"
|
||||
|
||||
`body > main > div > section.space-y-3 > p`
|
||||
- 삭제
|
||||
|
||||
`body > main > div > section:nth-child(2) > div.font-mono.text-xs.text-cobalt-dark.uppercase.tracking-widest.font-bold`
|
||||
- 삭제
|
||||
|
||||
`body > main > div > section:nth-child(2) > div.font-mono.text-xs.text-cobalt-dark.uppercase.tracking-widest.font-bold`
|
||||
- "고석주 교수" -> "Prof. Seok-Joo Koh"로 변경
|
||||
|
||||
`body > main > div > section:nth-child(2) > div.bg-paper.p-8.rounded-lg.border.border-line.grid.grid-cols-1.lg\:grid-cols-12.gap-8.items-center > div.lg\:col-span-8.space-y-4 > div.flex.items-baseline.gap-3 > span`
|
||||
- 삭제
|
||||
|
||||
`body > main > div > section:nth-child(2) > div.bg-paper.p-8.rounded-lg.border.border-line.grid.grid-cols-1.lg\:grid-cols-12.gap-8.items-center > div.lg\:col-span-8.space-y-4 > p`
|
||||
|
||||
- 다음 세부 커리어를 기반으로 설명 작성 (영어로) 및 details 링크 연결.
|
||||
- details 링크 클릭 시 modal dialog를 통해 아래 세부 내용 보여주기
|
||||
|
||||
```
|
||||
# Education
|
||||
- KAIST, Management Science, B. S., 1992. 2.
|
||||
- KAIST, Management Science, M. S., 1994. 2.
|
||||
- KAIST, Industrial Engineering, Ph. D., 1998. 8.
|
||||
|
||||
# Career
|
||||
|
||||
2024. 3 - Present
|
||||
Dean
|
||||
College of IT Engineering (CITE)
|
||||
Kyungpook National University (KNU)
|
||||
|
||||
2015. 12 - Present
|
||||
Director
|
||||
Institute of Software Education (SWEDU)
|
||||
Kyungpook National University (KNU)
|
||||
|
||||
2004. 3 - Present
|
||||
Professor
|
||||
School of Computer Science and Engineering (CSE)
|
||||
College of IT Engineering (CITE)
|
||||
Kyungpook National University (KNU)
|
||||
|
||||
1998. 8 - 2004. 2
|
||||
Senior Researcher
|
||||
Protocol Engineering Center (PEC)
|
||||
Electronics Telecommunications Research Institute (ETRI)
|
||||
|
||||
# International Standardization
|
||||
- ISO/IEC JTC1 SC6/WG7: Reliable/Mobile Multicasting (1999 ~ 2019): Project Editor
|
||||
- ISO/IEC JTC1 SC41/WG5: Internet-of-Things (IoT) Applications (2020 ~ Present): Project Editor
|
||||
- ITU-T SG13: Mobility Management (1999 ~ 2015): Project Editor
|
||||
- ITU-T SG20: VLC-based IoT (2018 ~ 2020): Project Editor
|
||||
- IEC TC100: Multimedia Systems and Equipment (2018 ~ Present): Project Editor, TA15 TAM, WG11 Convenor
|
||||
|
||||
# Membership on Academic Society
|
||||
|
||||
- KIPS (Korea Information Processing Society)
|
||||
- KICS (Korean Institute of Communications and Information Sciences)
|
||||
- KIISE (Korean Institute of Information Scientists and Engineers)
|
||||
- IEEE (Institute of Electrical and Electronics Engineers)
|
||||
```
|
||||
|
||||
`body > main > div > section.space-y-8.pt-6.md\:pt-10.border-t.border-line > div.flex.justify-between.items-end > div > div`
|
||||
- 삭제
|
||||
|
||||
`body > main > div > section.space-y-8.pt-6.md\:pt-10.border-t.border-line > div.flex.justify-between.items-end > div > h2`
|
||||
- "재학 연구원" -> Active Researchers
|
||||
- 글자색 변경: primary color
|
||||
|
||||
`body > main > div > section.space-y-8.pt-6.md\:pt-10.border-t.border-line > div:nth-child(2)`
|
||||
- PhD -> Ph. D
|
||||
- PhDCandidate -> Ph. D. Candidate
|
||||
- PhDStudent -> Ph. D. Student
|
||||
|
||||
`body > main > div > section.space-y-8.pt-6.md\:pt-10.border-t.border-line > div.space-y-4.pt-4`
|
||||
- MSCandidate -> M. S. Candidate
|
||||
- MSStudent -> M. S. Student
|
||||
|
||||
`body > main > div > section:nth-child(4) > div > div > div`
|
||||
- 삭제
|
||||
|
||||
`body > main > div > section:nth-child(4) > div > div > h2`
|
||||
- 졸업생 목록 -> "Graduates"
|
||||
|
||||
`body > main > div > section:nth-child(4) > details > summary > div > span`
|
||||
- 영어로 작성
|
||||
`body > main > div > section:nth-child(4) > details > summary > div > p`
|
||||
- 영어로 작성
|
||||
|
||||
`body > main > div > section:nth-child(4) > details > summary > span > span.group-open\:hidden`
|
||||
- 전체 보기 -> Open
|
||||
|
||||
- 삭제된 component들의 크기를 반영하여 화면 비율 조정
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
*.db
|
||||
*.log
|
||||
.git
|
||||
.env
|
||||
@@ -0,0 +1,22 @@
|
||||
# ==============================================================================
|
||||
# AI Agent Networking Lab (ANL) Backend API Server Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Server binding host and port
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# SQLite database file path (Inside container: /app/data/anl.db)
|
||||
DB_PATH=anl.db
|
||||
|
||||
# Gin runtime mode: release | debug | test
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
# Binaries and build outputs
|
||||
/bin/
|
||||
/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
|
||||
@@ -0,0 +1,58 @@
|
||||
# ==============================================================================
|
||||
# Stage 1: Build the static frontend export
|
||||
# ==============================================================================
|
||||
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
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
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 3: Minimal Alpine runtime — single container, single process
|
||||
# ==============================================================================
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
|
||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
|
||||
|
||||
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
|
||||
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
DB_PATH=/app/data/anl.db \
|
||||
GIN_MODE=release \
|
||||
CORS_ALLOW_ORIGINS=* \
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
EXPOSE 8080
|
||||
USER appuser
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/api/v1/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/bin/api"]
|
||||
@@ -0,0 +1,47 @@
|
||||
.PHONY: all build run seed export-content test fmt fmt-check vet clean
|
||||
|
||||
BIN_DIR := bin
|
||||
DB_PATH := anl.db
|
||||
|
||||
all: fmt-check vet build test
|
||||
|
||||
build:
|
||||
@mkdir -p $(BIN_DIR)
|
||||
@echo "Building api server..."
|
||||
go build -o $(BIN_DIR)/api ./cmd/api
|
||||
@echo "Building seeder..."
|
||||
go build -o $(BIN_DIR)/seed ./cmd/seed
|
||||
@echo "Building content exporter..."
|
||||
go build -o $(BIN_DIR)/export-content ./cmd/export-content
|
||||
|
||||
run: build
|
||||
./$(BIN_DIR)/api --db $(DB_PATH)
|
||||
|
||||
seed: build
|
||||
./$(BIN_DIR)/seed --db $(DB_PATH) --seed-dir seed/data
|
||||
|
||||
export-content: build
|
||||
@echo "⚠️ Notice: Frontend has migrated to real-time dynamic runtime fetching (force-dynamic)."
|
||||
@echo "export-content is deprecated to prevent re-introducing static content data files."
|
||||
./$(BIN_DIR)/export-content --db $(DB_PATH) --out-dir ../refer_landing_page/content
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || (echo "gofmt check failed on files:" && gofmt -l . && exit 1)
|
||||
|
||||
vet:
|
||||
go 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*
|
||||
@@ -0,0 +1,129 @@
|
||||
# AI Agent Networking Lab (ANL) — Go/Gin/SQLite Backend API
|
||||
|
||||
This module implements the standalone backend REST API server, SQLite database storage, seed ingestion pipeline, and static content exporter for the ANL landing page.
|
||||
|
||||
---
|
||||
|
||||
## 🏛 Architecture Overview
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------+
|
||||
| SQLite (anl.db) |
|
||||
| 11 Relational Tables (fProjects, Members, Pubs, Lectures) |
|
||||
+-------------------------------------------------------------+
|
||||
▲ ▲
|
||||
│ (cmd/seed) │ (SQL query)
|
||||
│ │
|
||||
+-------------------------------+ +-------------------------+
|
||||
| seed/data/*.json | | Gin REST API Server |
|
||||
| (extracted from content/*.ts) | | (cmd/api, :8080) |
|
||||
+-------------------------------+ +-------------------------+
|
||||
│
|
||||
+-------------------------+
|
||||
| Content Exporter |
|
||||
| (cmd/export-content) |
|
||||
+-------------------------+
|
||||
│
|
||||
▼
|
||||
+-------------------------+
|
||||
| Next.js Static SSG |
|
||||
| (refer_landing_page/) |
|
||||
+-------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Build Binaries
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
Creates `bin/api`, `bin/seed`, and `bin/export-content`.
|
||||
|
||||
### 2. Run Migrations & Seed Database
|
||||
```bash
|
||||
make seed
|
||||
```
|
||||
Creates `anl.db`, executes SQL DDL migrations, inserts seed data, and verifies all 11 table row counts and the 5 designated highlights.
|
||||
|
||||
### 3. Run Backend API Server
|
||||
```bash
|
||||
make run
|
||||
# Or with options:
|
||||
./bin/api --port 8080 --db anl.db
|
||||
```
|
||||
Server starts at `http://localhost:8080/api/v1`.
|
||||
|
||||
### 4. Run Unit & Integration Tests
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
### 5. Export Content to Next.js Frontend
|
||||
```bash
|
||||
make export-content
|
||||
```
|
||||
Regenerates `refer_landing_page/content/*.ts` from SQLite with typed `isHighlight` metadata.
|
||||
|
||||
---
|
||||
|
||||
## 📡 REST API Endpoints (`/api/v1`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/health` | Service health status check |
|
||||
| `GET` | `/api/v1/research-projects` | 3 research project entities |
|
||||
| `GET` | `/api/v1/research-areas` | 14 core research areas ordered by `display_order` |
|
||||
| `GET` | `/api/v1/stats/summary` | Aggregated counts (`intl_publications`, `standardization_docs`, `patents`) |
|
||||
| `GET` | `/api/v1/members` | 16 active members (with `is_advisor` flag) |
|
||||
| `GET` | `/api/v1/alumni` | 60 alumni ordered chronologically |
|
||||
| `GET` | `/api/v1/publications` | 148 publications (supports `?category=intl-journal-conf` or `domestic-journal-conf`) |
|
||||
| `GET` | `/api/v1/publications/highlights` | 5 designated top representative publications (`WHERE is_highlight = 1`) |
|
||||
| `GET` | `/api/v1/patents` | 75 intellectual property patents |
|
||||
| `GET` | `/api/v1/semesters` | 45 semesters with nested courses, ordered by year DESC and term |
|
||||
| `GET` | `/api/v1/standards-bodies` | 6 standards bodies with nested projects and documents (deterministic ordering) |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Environment Configuration (`.env`)
|
||||
|
||||
Copy `.env.example` to `.env` to customize runtime settings:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `HOST` | `0.0.0.0` | Binding host IP address |
|
||||
| `PORT` | `8080` | Server listening port |
|
||||
| `DB_PATH` | `anl.db` | SQLite database file path |
|
||||
| `GIN_MODE` | `release` | Gin runtime mode (`release`, `debug`, `test`) |
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | Allowed CORS origins (e.g. `https://anl.knu.ac.kr`) |
|
||||
| `AUTO_MIGRATE` | `true` | Apply database migrations automatically on startup |
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker & Containerization
|
||||
|
||||
### Build & Run Container
|
||||
```bash
|
||||
docker build -t anl-backend-api .
|
||||
docker run -p 8080:8080 -v anl-data:/app/data anl-backend-api
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
Run the full backend service with automated healthchecks and volume persistence from the repository root:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Database Schema & Specifications
|
||||
|
||||
* **Architecture**: See [ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
* **REST API Interfaces**: See [API_INTERFACE.md](../API_INTERFACE.md)
|
||||
* **Database Schema Spec**: See [DATA_TABLE.md](../DATA_TABLE.md) and [000001_init.up.sql](internal/db/migrations/000001_init.up.sql)
|
||||
@@ -0,0 +1,76 @@
|
||||
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"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables & .env
|
||||
cfg := config.Load()
|
||||
|
||||
// Optional CLI flags override environment defaults
|
||||
portFlag := flag.Int("port", 0, "Port for the Gin server to listen on (overrides PORT)")
|
||||
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 {
|
||||
cfg.Port = *portFlag
|
||||
}
|
||||
if *hostFlag != "" {
|
||||
cfg.Host = *hostFlag
|
||||
}
|
||||
if *dbPathFlag != "" {
|
||||
cfg.DBPath = *dbPathFlag
|
||||
}
|
||||
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)
|
||||
|
||||
log.Printf("Connecting to SQLite database at %s...", cfg.DBPath)
|
||||
database, err := db.Connect(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if cfg.AutoMigrate {
|
||||
log.Println("Ensuring database schema migrations are applied...")
|
||||
if err := db.RunMigrations(database); err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("Server failed to run: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
)
|
||||
|
||||
func marshalJSONNoEscapeHTML(data any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
func formatTSFile(importStmt, exportConstName, typeAnnotation string, data any) ([]byte, error) {
|
||||
jsonData, err := marshalJSONNoEscapeHTML(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
header := "// AUTO-GENERATED by backend/cmd/export-content — DO NOT EDIT BY HAND\n"
|
||||
content := fmt.Sprintf("%s%s\n\nexport const %s: %s = %s;\n", header, importStmt, exportConstName, typeAnnotation, string(jsonData))
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
func writeFileSafe(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.LoadEnvFile()
|
||||
defaultDB := "anl.db"
|
||||
if envDB := os.Getenv("DB_PATH"); envDB != "" {
|
||||
defaultDB = envDB
|
||||
}
|
||||
|
||||
dbPath := flag.String("db", defaultDB, "Path to SQLite database file")
|
||||
targetDir := flag.String("out-dir", "../refer_landing_page/content", "Target content directory")
|
||||
force := flag.Bool("force", false, "Force export content files despite dynamic runtime architecture")
|
||||
flag.Parse()
|
||||
|
||||
if !*force {
|
||||
log.Printf("⚠️ NOTICE: Frontend architecture has migrated to real-time dynamic runtime fetching (force-dynamic).")
|
||||
log.Printf("Exporting static TypeScript files to %s is DEPRECATED to prevent re-introducing static fallback leaks.", *targetDir)
|
||||
log.Printf("If you really need to export fallback files, pass the --force flag.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Exporting content from SQLite (%s) to TypeScript files in %s (forced)...", *dbPath, *targetDir)
|
||||
database, err := db.Connect(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
homeRepo := repository.NewHomeRepository(database)
|
||||
membersRepo := repository.NewMembersRepository(database)
|
||||
pubsRepo := repository.NewPublicationsRepository(database)
|
||||
lecturesRepo := repository.NewLecturesRepository(database)
|
||||
standardsRepo := repository.NewStandardizationRepository(database)
|
||||
|
||||
// 1. Projects
|
||||
projects, err := homeRepo.GetResearchProjects(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Get research projects: %v", err)
|
||||
}
|
||||
type TSProject struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Abstract string `json:"abstract"`
|
||||
Keywords []string `json:"keywords"`
|
||||
StandardsTrack *string `json:"standardsTrack,omitempty"`
|
||||
StandardsOrg *string `json:"standardsOrg,omitempty"`
|
||||
Period *string `json:"period,omitempty"`
|
||||
Deliverables []any `json:"deliverables"`
|
||||
Funder *string `json:"funder,omitempty"`
|
||||
}
|
||||
var tsProjects []TSProject
|
||||
for _, p := range projects {
|
||||
tsProjects = append(tsProjects, TSProject{
|
||||
Slug: p.Slug,
|
||||
Title: p.Title,
|
||||
Abstract: p.Abstract,
|
||||
Keywords: p.Keywords,
|
||||
StandardsTrack: p.Organization,
|
||||
StandardsOrg: p.StandardsOrg,
|
||||
Period: p.Period,
|
||||
Deliverables: []any{},
|
||||
Funder: p.Funder,
|
||||
})
|
||||
}
|
||||
pBytes, err := formatTSFile(`import { ResearchProject } from "./types";`, "researchProjects", "ResearchProject[]", tsProjects)
|
||||
if err != nil {
|
||||
log.Fatalf("Format projects.ts failed: %v", err)
|
||||
}
|
||||
if err := writeFileSafe(filepath.Join(*targetDir, "projects.ts"), pBytes); err != nil {
|
||||
log.Fatalf("Write projects.ts failed: %v", err)
|
||||
}
|
||||
log.Println("✓ Exported projects.ts")
|
||||
|
||||
// 2. Members & Alumni
|
||||
members, err := membersRepo.GetMembers(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Get members: %v", err)
|
||||
}
|
||||
type TSMember struct {
|
||||
Ko string `json:"ko"`
|
||||
En string `json:"en"`
|
||||
Degree string `json:"degree"`
|
||||
Affiliation *string `json:"affiliation,omitempty"`
|
||||
}
|
||||
var tsMembers []TSMember
|
||||
for _, m := range members {
|
||||
tsMembers = append(tsMembers, TSMember{
|
||||
Ko: m.NameKo,
|
||||
En: m.NameEn,
|
||||
Degree: m.Degree,
|
||||
Affiliation: m.Affiliation,
|
||||
})
|
||||
}
|
||||
|
||||
alumni, err := membersRepo.GetAlumni(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Get alumni: %v", err)
|
||||
}
|
||||
type TSAlumnus struct {
|
||||
Ko string `json:"ko"`
|
||||
En string `json:"en"`
|
||||
Degree string `json:"degree"`
|
||||
GraduatedAt string `json:"graduatedAt"`
|
||||
Major *string `json:"major,omitempty"`
|
||||
CurrentPosition *string `json:"currentPosition,omitempty"`
|
||||
}
|
||||
var tsAlumni []TSAlumnus
|
||||
for _, a := range alumni {
|
||||
tsAlumni = append(tsAlumni, TSAlumnus{
|
||||
Ko: a.NameKo,
|
||||
En: a.NameEn,
|
||||
Degree: a.Degree,
|
||||
GraduatedAt: a.GraduatedAt,
|
||||
Major: a.Major,
|
||||
CurrentPosition: a.CurrentPosition,
|
||||
})
|
||||
}
|
||||
|
||||
mJSON, err := marshalJSONNoEscapeHTML(tsMembers)
|
||||
if err != nil {
|
||||
log.Fatalf("Marshal members failed: %v", err)
|
||||
}
|
||||
aJSON, err := marshalJSONNoEscapeHTML(tsAlumni)
|
||||
if err != nil {
|
||||
log.Fatalf("Marshal alumni failed: %v", err)
|
||||
}
|
||||
membersTS := fmt.Sprintf("// AUTO-GENERATED by backend/cmd/export-content — DO NOT EDIT BY HAND\nimport { Member, Alumnus } from \"./types\";\n\nexport const activeMembers: Member[] = %s;\n\nexport const alumni: Alumnus[] = %s;\n", string(mJSON), string(aJSON))
|
||||
if err := writeFileSafe(filepath.Join(*targetDir, "members.ts"), []byte(membersTS)); err != nil {
|
||||
log.Fatalf("Write members.ts failed: %v", err)
|
||||
}
|
||||
log.Println("✓ Exported members.ts")
|
||||
|
||||
// 3. Publications & Patents
|
||||
pubs, err := pubsRepo.GetPublications(ctx, "")
|
||||
if err != nil {
|
||||
log.Fatalf("Get publications: %v", err)
|
||||
}
|
||||
type TSPublication struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Authors *string `json:"authors,omitempty"`
|
||||
Venue string `json:"venue"`
|
||||
Volume *string `json:"volume,omitempty"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
KCI bool `json:"kci,omitempty"`
|
||||
DOI *string `json:"doi,omitempty"`
|
||||
IsHighlight bool `json:"isHighlight,omitempty"`
|
||||
}
|
||||
var tsPubs []TSPublication
|
||||
for _, p := range pubs {
|
||||
tsPubs = append(tsPubs, TSPublication{
|
||||
Category: p.Category,
|
||||
Title: p.Title,
|
||||
Authors: p.Authors,
|
||||
Venue: p.Venue,
|
||||
Volume: p.Volume,
|
||||
PublishedAt: p.PublishedAt,
|
||||
KCI: p.KCI,
|
||||
DOI: p.DOI,
|
||||
IsHighlight: p.IsHighlight,
|
||||
})
|
||||
}
|
||||
|
||||
patents, err := pubsRepo.GetPatents(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Get patents: %v", err)
|
||||
}
|
||||
type TSPatent struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Inventors string `json:"inventors"`
|
||||
ApplicationNo string `json:"applicationNo"`
|
||||
ApplicationAt string `json:"applicationAt"`
|
||||
RegistrationNo *string `json:"registrationNo,omitempty"`
|
||||
RegistrationAt *string `json:"registrationAt,omitempty"`
|
||||
Country string `json:"country"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
var tsPatents []TSPatent
|
||||
for _, pat := range patents {
|
||||
tsPatents = append(tsPatents, TSPatent{
|
||||
Category: "patent",
|
||||
Title: pat.Title,
|
||||
Inventors: pat.Inventors,
|
||||
ApplicationNo: pat.ApplicationNo,
|
||||
ApplicationAt: pat.ApplicationAt,
|
||||
RegistrationNo: pat.RegistrationNo,
|
||||
RegistrationAt: pat.RegistrationAt,
|
||||
Country: pat.Country,
|
||||
PublishedAt: pat.PublishedAt,
|
||||
})
|
||||
}
|
||||
|
||||
pubsJSON, err := marshalJSONNoEscapeHTML(tsPubs)
|
||||
if err != nil {
|
||||
log.Fatalf("Marshal publications failed: %v", err)
|
||||
}
|
||||
patentsJSON, err := marshalJSONNoEscapeHTML(tsPatents)
|
||||
if err != nil {
|
||||
log.Fatalf("Marshal patents failed: %v", err)
|
||||
}
|
||||
pubsTS := fmt.Sprintf("// AUTO-GENERATED by backend/cmd/export-content — DO NOT EDIT BY HAND\nimport { Publication, Patent } from \"./types\";\n\nexport const publications: Publication[] = %s;\n\nexport const patents: Patent[] = %s;\n", string(pubsJSON), string(patentsJSON))
|
||||
if err := writeFileSafe(filepath.Join(*targetDir, "publications.ts"), []byte(pubsTS)); err != nil {
|
||||
log.Fatalf("Write publications.ts failed: %v", err)
|
||||
}
|
||||
log.Println("✓ Exported publications.ts (with isHighlight metadata & unescaped HTML entities)")
|
||||
|
||||
// 4. Lectures
|
||||
semesters, err := lecturesRepo.GetSemestersWithCourses(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Get semesters: %v", err)
|
||||
}
|
||||
type TSCourse struct {
|
||||
Code string `json:"code"`
|
||||
En string `json:"en"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
}
|
||||
type TSSemester struct {
|
||||
Term string `json:"term"`
|
||||
Year int `json:"year"`
|
||||
Courses []TSCourse `json:"courses"`
|
||||
}
|
||||
var tsSemesters []TSSemester
|
||||
for _, s := range semesters {
|
||||
var tsCourses []TSCourse
|
||||
for _, c := range s.Courses {
|
||||
tsCourses = append(tsCourses, TSCourse{
|
||||
Code: c.Code,
|
||||
En: c.NameEn,
|
||||
Note: c.Note,
|
||||
})
|
||||
}
|
||||
tsSemesters = append(tsSemesters, TSSemester{
|
||||
Term: s.Term,
|
||||
Year: s.Year,
|
||||
Courses: tsCourses,
|
||||
})
|
||||
}
|
||||
lecturesBytes, err := formatTSFile(`import { Semester } from "./types";`, "semesters", "Semester[]", tsSemesters)
|
||||
if err != nil {
|
||||
log.Fatalf("Format lectures.ts failed: %v", err)
|
||||
}
|
||||
if err := writeFileSafe(filepath.Join(*targetDir, "lectures.ts"), lecturesBytes); err != nil {
|
||||
log.Fatalf("Write lectures.ts failed: %v", err)
|
||||
}
|
||||
log.Println("✓ Exported lectures.ts")
|
||||
|
||||
// 5. Standards
|
||||
standards, err := standardsRepo.GetStandardsBodiesWithProjects(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Get standards bodies: %v", err)
|
||||
}
|
||||
type TSDoc struct {
|
||||
Title string `json:"title"`
|
||||
Ref string `json:"ref"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *string `json:"publishedAt,omitempty"`
|
||||
}
|
||||
type TSStandardProject struct {
|
||||
WG string `json:"wg"`
|
||||
Name string `json:"name"`
|
||||
Documents []TSDoc `json:"documents"`
|
||||
}
|
||||
type TSStandardsBody struct {
|
||||
Org string `json:"org"`
|
||||
Full string `json:"full"`
|
||||
Scope string `json:"scope"`
|
||||
Period string `json:"period"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Projects []TSStandardProject `json:"projects"`
|
||||
}
|
||||
var tsStandards []TSStandardsBody
|
||||
for _, b := range standards {
|
||||
var tsProjs []TSStandardProject
|
||||
for _, p := range b.Projects {
|
||||
var tsDocs []TSDoc
|
||||
for _, d := range p.Documents {
|
||||
tsDocs = append(tsDocs, TSDoc{
|
||||
Title: d.Title,
|
||||
Ref: d.DocRef,
|
||||
Status: d.Status,
|
||||
PublishedAt: d.PublishedAt,
|
||||
})
|
||||
}
|
||||
tsProjs = append(tsProjs, TSStandardProject{
|
||||
WG: p.WG,
|
||||
Name: p.Name,
|
||||
Documents: tsDocs,
|
||||
})
|
||||
}
|
||||
tsStandards = append(tsStandards, TSStandardsBody{
|
||||
Org: b.Org,
|
||||
Full: b.FullName,
|
||||
Scope: b.Scope,
|
||||
Period: b.Period,
|
||||
Role: b.Role,
|
||||
Projects: tsProjs,
|
||||
})
|
||||
}
|
||||
standardsBytes, err := formatTSFile(`import { StandardsBody } from "./types";`, "standardsBodies", "StandardsBody[]", tsStandards)
|
||||
if err != nil {
|
||||
log.Fatalf("Format standards.ts failed: %v", err)
|
||||
}
|
||||
if err := writeFileSafe(filepath.Join(*targetDir, "standards.ts"), standardsBytes); err != nil {
|
||||
log.Fatalf("Write standards.ts failed: %v", err)
|
||||
}
|
||||
log.Println("✓ Exported standards.ts")
|
||||
|
||||
log.Println("=== All content successfully exported from SQLite! ===")
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
)
|
||||
|
||||
type SeedProject struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Abstract string `json:"abstract"`
|
||||
Keywords []string `json:"keywords"`
|
||||
StandardsTrack *string `json:"standardsTrack"`
|
||||
StandardsOrg *string `json:"standardsOrg"`
|
||||
Period *string `json:"period"`
|
||||
Funder *string `json:"funder"`
|
||||
}
|
||||
|
||||
type SeedArea struct {
|
||||
NameEn string `json:"name_en"`
|
||||
NameKr *string `json:"name_kr"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
type SeedMember struct {
|
||||
NameKo string `json:"name_ko"`
|
||||
NameEn string `json:"name_en"`
|
||||
Degree string `json:"degree"`
|
||||
Affiliation *string `json:"affiliation"`
|
||||
Email *string `json:"email"`
|
||||
IsAdvisor bool `json:"is_advisor"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
type SeedAlumnus struct {
|
||||
NameKo string `json:"name_ko"`
|
||||
NameEn string `json:"name_en"`
|
||||
Degree string `json:"degree"`
|
||||
GraduatedAt string `json:"graduated_at"`
|
||||
Major *string `json:"major"`
|
||||
CurrentPosition *string `json:"current_position"`
|
||||
}
|
||||
|
||||
type SeedPublication struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Authors *string `json:"authors"`
|
||||
Venue string `json:"venue"`
|
||||
Volume *string `json:"volume"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
KCI bool `json:"kci"`
|
||||
DOI *string `json:"doi"`
|
||||
IsHighlight bool `json:"is_highlight"`
|
||||
}
|
||||
|
||||
type SeedPatent struct {
|
||||
Title string `json:"title"`
|
||||
Inventors string `json:"inventors"`
|
||||
ApplicationNo string `json:"application_no"`
|
||||
ApplicationAt string `json:"application_at"`
|
||||
RegistrationNo *string `json:"registration_no"`
|
||||
RegistrationAt *string `json:"registration_at"`
|
||||
Country string `json:"country"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
type SeedCourse struct {
|
||||
Code string `json:"code"`
|
||||
En string `json:"en"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
type SeedSemester struct {
|
||||
Term string `json:"term"`
|
||||
Year int `json:"year"`
|
||||
Courses []SeedCourse `json:"courses"`
|
||||
}
|
||||
|
||||
type SeedDoc struct {
|
||||
Title string `json:"title"`
|
||||
Ref string `json:"ref"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *string `json:"publishedAt"`
|
||||
}
|
||||
|
||||
type SeedStandardProject struct {
|
||||
WG string `json:"wg"`
|
||||
Name string `json:"name"`
|
||||
Documents []SeedDoc `json:"documents"`
|
||||
}
|
||||
|
||||
type SeedStandardsBody struct {
|
||||
Org string `json:"org"`
|
||||
Full string `json:"full"`
|
||||
Scope string `json:"scope"`
|
||||
Period string `json:"period"`
|
||||
Role *string `json:"role"`
|
||||
Projects []SeedStandardProject `json:"projects"`
|
||||
}
|
||||
|
||||
func readJSONFile[T any](path string) (T, error) {
|
||||
var result T
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return result, fmt.Errorf("unmarshal %s: %w", path, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.LoadEnvFile()
|
||||
defaultDB := "anl.db"
|
||||
if envDB := os.Getenv("DB_PATH"); envDB != "" {
|
||||
defaultDB = envDB
|
||||
}
|
||||
|
||||
dbPath := flag.String("db", defaultDB, "SQLite database file path")
|
||||
seedDir := flag.String("seed-dir", "seed/data", "Directory containing seed JSON files")
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Connecting to SQLite database at %s...", *dbPath)
|
||||
database, err := db.Connect(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Database connection failed: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
log.Println("Applying schema migrations...")
|
||||
if err := db.RunMigrations(database); err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Fatalf("Begin transaction failed: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Clear existing records in reverse FK order
|
||||
tables := []string{
|
||||
"standard_documents", "standards_bodies",
|
||||
"courses", "semesters",
|
||||
"patents", "publications",
|
||||
"alumni", "members",
|
||||
"research_areas", "research_projects",
|
||||
}
|
||||
for _, t := range tables {
|
||||
if _, err := tx.Exec("DELETE FROM " + t); err != nil {
|
||||
log.Fatalf("Failed to clear table %s: %v", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Research Projects
|
||||
projects, err := readJSONFile[[]SeedProject](filepath.Join(*seedDir, "research_projects.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read research projects: %v", err)
|
||||
}
|
||||
for _, p := range projects {
|
||||
kwJSON, _ := json.Marshal(p.Keywords)
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Slug, p.Title, p.Abstract, string(kwJSON), p.StandardsTrack, p.StandardsOrg, p.Period, p.Funder)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert research project (%s) failed: %v", p.Slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Research Areas
|
||||
areas, err := readJSONFile[[]SeedArea](filepath.Join(*seedDir, "research_areas.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read research areas: %v", err)
|
||||
}
|
||||
for _, a := range areas {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||
VALUES (?, ?, ?)
|
||||
`, a.NameEn, a.NameKr, a.DisplayOrder)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert research area failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Members
|
||||
members, err := readJSONFile[[]SeedMember](filepath.Join(*seedDir, "members.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read members: %v", err)
|
||||
}
|
||||
for _, 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)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert member (%s) failed: %v", m.NameEn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Alumni
|
||||
alumni, err := readJSONFile[[]SeedAlumnus](filepath.Join(*seedDir, "alumni.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read alumni: %v", err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert alumnus (%s) failed: %v", a.NameEn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Publications
|
||||
pubs, err := readJSONFile[[]SeedPublication](filepath.Join(*seedDir, "publications.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read publications: %v", err)
|
||||
}
|
||||
for _, p := range pubs {
|
||||
_, err := tx.Exec(`
|
||||
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 {
|
||||
log.Fatalf("Insert publication (%s) failed: %v", p.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Patents
|
||||
patents, err := readJSONFile[[]SeedPatent](filepath.Join(*seedDir, "patents.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read patents: %v", err)
|
||||
}
|
||||
for _, pat := 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)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert patent (%s) failed: %v", pat.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Semesters & Courses
|
||||
semesters, err := readJSONFile[[]SeedSemester](filepath.Join(*seedDir, "lectures.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read lectures: %v", err)
|
||||
}
|
||||
for _, s := range semesters {
|
||||
res, err := tx.Exec(`INSERT INTO semesters (year, term) VALUES (?, ?)`, s.Year, s.Term)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert semester (%d %s) failed: %v", s.Year, s.Term, err)
|
||||
}
|
||||
semID, _ := res.LastInsertId()
|
||||
|
||||
for cIdx, c := range s.Courses {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, semID, c.Code, c.En, nil, c.Note, cIdx+1)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert course (%s) failed: %v", c.Code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Standards Bodies & Documents
|
||||
standards, err := readJSONFile[[]SeedStandardsBody](filepath.Join(*seedDir, "standards.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read standards: %v", err)
|
||||
}
|
||||
for bIdx, b := range standards {
|
||||
res, err := tx.Exec(`
|
||||
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, b.Org, b.Full, b.Scope, b.Period, b.Role, bIdx+1)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert standards body (%s) failed: %v", b.Org, err)
|
||||
}
|
||||
bodyID, _ := res.LastInsertId()
|
||||
|
||||
for _, p := range b.Projects {
|
||||
for _, d := range p.Documents {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, bodyID, p.WG, p.Name, d.Title, d.Ref, d.Status, d.PublishedAt)
|
||||
if err != nil {
|
||||
log.Fatalf("Insert standard document (%s) failed: %v", d.Title, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Fatalf("Transaction commit failed: %v", err)
|
||||
}
|
||||
log.Println("Transaction committed successfully.")
|
||||
|
||||
// Verify Counts
|
||||
log.Println("=== Verifying Seeding Counts ===")
|
||||
checkCount := func(table string, expected int) {
|
||||
var cnt int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&cnt); err != nil {
|
||||
log.Fatalf("Count check failed for %s: %v", table, err)
|
||||
}
|
||||
if cnt != expected {
|
||||
log.Fatalf("Count mismatch for %s: expected %d, got %d", table, expected, cnt)
|
||||
}
|
||||
log.Printf("✓ %-20s : %3d rows (matches expected)", table, cnt)
|
||||
}
|
||||
|
||||
checkCount("research_projects", 3)
|
||||
checkCount("research_areas", 14)
|
||||
checkCount("members", 16)
|
||||
checkCount("alumni", 60)
|
||||
checkCount("publications", 148)
|
||||
checkCount("patents", 75)
|
||||
checkCount("semesters", 45)
|
||||
checkCount("courses", 132)
|
||||
checkCount("standards_bodies", 6)
|
||||
checkCount("standard_documents", 44)
|
||||
|
||||
// Explicit highlight assertion
|
||||
var hlCount int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM publications WHERE is_highlight = 1").Scan(&hlCount); err != nil {
|
||||
log.Fatalf("Highlight count query failed: %v", err)
|
||||
}
|
||||
if hlCount != 5 {
|
||||
log.Fatalf("Highlight count mismatch: expected exactly 5, got %d", hlCount)
|
||||
}
|
||||
log.Printf("✓ %-20s : %3d rows (exactly 5 highlights)", "publications(highlight)", hlCount)
|
||||
|
||||
log.Println("=== Seeding & Verification Completed Successfully! ===")
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,49 @@
|
||||
module git.godopu.com/lab/landing_page/backend
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
modernc.org/sqlite v1.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/arch v0.30.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
modernc.org/libc v1.75.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
||||
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.4 h1:EHQJNYDC6LiDIOqM76862xe4frbDc7IzEOZTtGxZV8Q=
|
||||
modernc.org/libc v1.75.4/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,110 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds all server configuration derived from environment variables and flags.
|
||||
type Config struct {
|
||||
Port int
|
||||
Host string
|
||||
DBPath string
|
||||
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.
|
||||
func LoadEnvFile(filenames ...string) {
|
||||
if len(filenames) == 0 {
|
||||
filenames = []string{".env", "../.env"}
|
||||
}
|
||||
|
||||
for _, filename := range filenames {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
// Strip optional surrounding quotes
|
||||
val = strings.Trim(val, `"'`)
|
||||
|
||||
// Only set if not already present in environment
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads and parses all configuration settings from the environment.
|
||||
func Load() *Config {
|
||||
LoadEnvFile()
|
||||
|
||||
cfg := &Config{
|
||||
Port: 8080,
|
||||
Host: "0.0.0.0",
|
||||
DBPath: "anl.db",
|
||||
GinMode: "release",
|
||||
CORSAllowOrigins: "*",
|
||||
AutoMigrate: true,
|
||||
AdminToken: "",
|
||||
}
|
||||
|
||||
if portStr := os.Getenv("PORT"); portStr != "" {
|
||||
if p, err := strconv.Atoi(portStr); err == nil && p > 0 {
|
||||
cfg.Port = p
|
||||
}
|
||||
}
|
||||
|
||||
if hostStr := os.Getenv("HOST"); hostStr != "" {
|
||||
cfg.Host = hostStr
|
||||
}
|
||||
|
||||
if dbPathStr := os.Getenv("DB_PATH"); dbPathStr != "" {
|
||||
cfg.DBPath = dbPathStr
|
||||
}
|
||||
|
||||
if ginModeStr := os.Getenv("GIN_MODE"); ginModeStr != "" {
|
||||
cfg.GinMode = ginModeStr
|
||||
}
|
||||
|
||||
if corsStr := os.Getenv("CORS_ALLOW_ORIGINS"); corsStr != "" {
|
||||
cfg.CORSAllowOrigins = corsStr
|
||||
}
|
||||
|
||||
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
||||
cfg.AdminToken = strings.Trim(token, " \"'\r\n\t")
|
||||
}
|
||||
|
||||
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Address returns the combined host:port address string.
|
||||
func (c *Config) Address() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Clear env
|
||||
_ = os.Unsetenv("PORT")
|
||||
_ = os.Unsetenv("HOST")
|
||||
_ = os.Unsetenv("DB_PATH")
|
||||
_ = 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)
|
||||
assert.Equal(t, "0.0.0.0", cfg.Host)
|
||||
assert.Equal(t, "anl.db", cfg.DBPath)
|
||||
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())
|
||||
}
|
||||
|
||||
func TestConfigEnvOverrides(t *testing.T) {
|
||||
_ = os.Setenv("PORT", "9090")
|
||||
_ = os.Setenv("HOST", "127.0.0.1")
|
||||
_ = os.Setenv("DB_PATH", "/tmp/custom.db")
|
||||
_ = 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")
|
||||
_ = os.Unsetenv("DB_PATH")
|
||||
_ = os.Unsetenv("GIN_MODE")
|
||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||
}()
|
||||
|
||||
cfg := config.Load()
|
||||
assert.Equal(t, 9090, cfg.Port)
|
||||
assert.Equal(t, "127.0.0.1", cfg.Host)
|
||||
assert.Equal(t, "/tmp/custom.db", cfg.DBPath)
|
||||
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())
|
||||
}
|
||||
|
||||
func TestLoadEnvFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
envPath := filepath.Join(tmpDir, ".env")
|
||||
envContent := `
|
||||
# Sample comment
|
||||
TEST_CUSTOM_KEY="hello_world"
|
||||
TEST_PORT_KEY=8888
|
||||
INVALID_LINE_WITHOUT_EQUALS
|
||||
`
|
||||
err := os.WriteFile(envPath, []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = os.Unsetenv("TEST_CUSTOM_KEY")
|
||||
_ = os.Unsetenv("TEST_PORT_KEY")
|
||||
defer func() {
|
||||
_ = os.Unsetenv("TEST_CUSTOM_KEY")
|
||||
_ = os.Unsetenv("TEST_PORT_KEY")
|
||||
}()
|
||||
|
||||
config.LoadEnvFile(envPath)
|
||||
assert.Equal(t, "hello_world", os.Getenv("TEST_CUSTOM_KEY"))
|
||||
assert.Equal(t, "8888", os.Getenv("TEST_PORT_KEY"))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/000001_init.up.sql
|
||||
var initMigrationUp string
|
||||
|
||||
//go:embed migrations/000001_init.down.sql
|
||||
var initMigrationDown string
|
||||
|
||||
// Connect opens an SQLite database connection with appropriate pragmas
|
||||
func Connect(dbPath string) (*sql.DB, error) {
|
||||
dsn := dbPath
|
||||
if !strings.Contains(dsn, "?") {
|
||||
dsn += "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// RunMigrations applies up migrations
|
||||
func RunMigrations(db *sql.DB) error {
|
||||
if _, err := db.Exec(initMigrationUp); err != nil {
|
||||
return fmt.Errorf("failed to apply migration up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackMigrations applies down migrations
|
||||
func RollbackMigrations(db *sql.DB) error {
|
||||
if _, err := db.Exec(initMigrationDown); err != nil {
|
||||
return fmt.Errorf("failed to apply migration down: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
DROP TABLE IF EXISTS standard_documents;
|
||||
DROP TABLE IF EXISTS standards_bodies;
|
||||
DROP TABLE IF EXISTS courses;
|
||||
DROP TABLE IF EXISTS semesters;
|
||||
DROP TABLE IF EXISTS patents;
|
||||
DROP TABLE IF EXISTS publications;
|
||||
DROP TABLE IF EXISTS alumni;
|
||||
DROP TABLE IF EXISTS members;
|
||||
DROP TABLE IF EXISTS research_areas;
|
||||
DROP TABLE IF EXISTS research_projects;
|
||||
@@ -0,0 +1,114 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_areas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name_en TEXT NOT NULL,
|
||||
name_kr TEXT,
|
||||
display_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_publications_category ON publications(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_publications_highlight ON publications(is_highlight) WHERE is_highlight = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semesters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
year INTEGER NOT NULL,
|
||||
term TEXT NOT NULL CHECK (term IN ('Spring','Fall')),
|
||||
UNIQUE(year, term)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_semester ON courses(semester_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stddocs_body ON standard_documents(body_id);
|
||||
@@ -0,0 +1,155 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type HomeHandler struct {
|
||||
repo *repository.HomeRepository
|
||||
}
|
||||
|
||||
func NewHomeHandler(repo *repository.HomeRepository) *HomeHandler {
|
||||
return &HomeHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
|
||||
projects, err := h.repo.GetResearchProjects(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve research projects: "+err.Error())
|
||||
return
|
||||
}
|
||||
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 {
|
||||
httpx.InternalServerError(c, "Failed to retrieve research areas: "+err.Error())
|
||||
return
|
||||
}
|
||||
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 {
|
||||
httpx.InternalServerError(c, "Failed to retrieve statistics summary: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, stats)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
|
||||
func NewLecturesHandler(repo *repository.LecturesRepository) *LecturesHandler {
|
||||
return &LecturesHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) GetSemesters(c *gin.Context) {
|
||||
semesters, err := h.repo.GetSemestersWithCourses(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve semesters: "+err.Error())
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
|
||||
func NewMembersHandler(repo *repository.MembersRepository) *MembersHandler {
|
||||
return &MembersHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *MembersHandler) GetMembers(c *gin.Context) {
|
||||
members, err := h.repo.GetMembers(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve members: "+err.Error())
|
||||
return
|
||||
}
|
||||
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 {
|
||||
httpx.InternalServerError(c, "Failed to retrieve alumni: "+err.Error())
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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
|
||||
}
|
||||
|
||||
func NewPublicationsHandler(repo *repository.PublicationsRepository) *PublicationsHandler {
|
||||
return &PublicationsHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||
category := c.Query("category")
|
||||
if category != "" && !validPubCategories[category] {
|
||||
httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
|
||||
pubs, err := h.repo.GetPublications(c.Request.Context(), category)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve publications: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, pubs)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) {
|
||||
patents, err := h.repo.GetPatents(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve patents: "+err.Error())
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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
|
||||
}
|
||||
|
||||
func NewStandardizationHandler(repo *repository.StandardizationRepository) *StandardizationHandler {
|
||||
return &StandardizationHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
|
||||
bodies, err := h.repo.GetStandardsBodiesWithProjects(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve standards bodies: "+err.Error())
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error ErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type SuccessResponse struct {
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, SuccessResponse{
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func Error(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, ErrorResponse{
|
||||
Error: ErrorDetail{
|
||||
Message: msg,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func NotFound(c *gin.Context, msg string) {
|
||||
Error(c, http.StatusNotFound, msg)
|
||||
}
|
||||
|
||||
func InternalServerError(c *gin.Context, msg string) {
|
||||
Error(c, http.StatusInternalServerError, msg)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ResearchProject struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Abstract string `json:"abstract"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
StandardsOrg *string `json:"standards_org,omitempty"`
|
||||
Period *string `json:"period,omitempty"`
|
||||
Funder *string `json:"funder,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ResearchArea struct {
|
||||
ID int `json:"id"`
|
||||
NameEn string `json:"name_en"`
|
||||
NameKr *string `json:"name_kr,omitempty"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
type StatsSummary struct {
|
||||
IntlPublications int `json:"intl_publications"`
|
||||
StandardizationDocs int `json:"standardization_docs"`
|
||||
Patents int `json:"patents"`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
type Course struct {
|
||||
ID int `json:"id"`
|
||||
SemesterID int `json:"semester_id"`
|
||||
Code string `json:"code"`
|
||||
NameEn string `json:"name_en"`
|
||||
NameKr *string `json:"name_kr,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
type Semester struct {
|
||||
ID int `json:"id"`
|
||||
Year int `json:"year"`
|
||||
Term string `json:"term"`
|
||||
}
|
||||
|
||||
type SemesterWithCourses struct {
|
||||
ID int `json:"id"`
|
||||
Year int `json:"year"`
|
||||
Term string `json:"term"`
|
||||
Courses []Course `json:"courses"`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Member struct {
|
||||
ID int `json:"id"`
|
||||
NameKo string `json:"name_ko"`
|
||||
NameEn string `json:"name_en"`
|
||||
Degree string `json:"degree"`
|
||||
Affiliation *string `json:"affiliation,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
IsAdvisor bool `json:"is_advisor"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Alumnus struct {
|
||||
ID int `json:"id"`
|
||||
NameKo string `json:"name_ko"`
|
||||
NameEn string `json:"name_en"`
|
||||
Degree string `json:"degree"`
|
||||
GraduatedAt string `json:"graduated_at"`
|
||||
Major *string `json:"major,omitempty"`
|
||||
CurrentPosition *string `json:"current_position,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Publication struct {
|
||||
ID int `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Authors *string `json:"authors,omitempty"`
|
||||
Venue string `json:"venue"`
|
||||
Volume *string `json:"volume,omitempty"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
KCI bool `json:"kci"`
|
||||
DOI *string `json:"doi,omitempty"`
|
||||
IsHighlight bool `json:"is_highlight"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Patent struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Inventors string `json:"inventors"`
|
||||
ApplicationNo string `json:"application_no"`
|
||||
ApplicationAt string `json:"application_at"`
|
||||
RegistrationNo *string `json:"registration_no,omitempty"`
|
||||
RegistrationAt *string `json:"registration_at,omitempty"`
|
||||
Country string `json:"country"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
type StandardDocument struct {
|
||||
ID int `json:"id"`
|
||||
BodyID int `json:"body_id"`
|
||||
WG string `json:"wg"`
|
||||
ProjectName string `json:"project_name"`
|
||||
Title string `json:"title"`
|
||||
DocRef string `json:"doc_ref"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *string `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
type StandardProjectGroup struct {
|
||||
WG string `json:"wg"`
|
||||
Name string `json:"name"`
|
||||
Documents []StandardDocument `json:"documents"`
|
||||
}
|
||||
|
||||
type StandardsBody struct {
|
||||
ID int `json:"id"`
|
||||
Org string `json:"org"`
|
||||
FullName string `json:"full_name"`
|
||||
Scope string `json:"scope"`
|
||||
Period string `json:"period"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
type StandardsBodyWithProjects struct {
|
||||
ID int `json:"id"`
|
||||
Org string `json:"org"`
|
||||
FullName string `json:"full_name"`
|
||||
Scope string `json:"scope"`
|
||||
Period string `json:"period"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
Projects []StandardProjectGroup `json:"projects"`
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
)
|
||||
|
||||
type HomeRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewHomeRepository(db *sql.DB) *HomeRepository {
|
||||
return &HomeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.ResearchProject, error) {
|
||||
query := `
|
||||
SELECT id, slug, title, abstract, keywords, organization, standards_org, period, funder, created_at, updated_at
|
||||
FROM research_projects
|
||||
ORDER BY id ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query research projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]models.ResearchProject, 0)
|
||||
for rows.Next() {
|
||||
var p models.ResearchProject
|
||||
var kwJSON string
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.Slug, &p.Title, &p.Abstract, &kwJSON,
|
||||
&p.Organization, &p.StandardsOrg, &p.Period, &p.Funder,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan research project: %w", err)
|
||||
}
|
||||
if kwJSON != "" {
|
||||
_ = json.Unmarshal([]byte(kwJSON), &p.Keywords)
|
||||
}
|
||||
if p.Keywords == nil {
|
||||
p.Keywords = []string{}
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
FROM research_areas
|
||||
ORDER BY display_order ASC, id ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query research areas: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("scan research area: %w", err)
|
||||
}
|
||||
areas = append(areas, a)
|
||||
}
|
||||
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
|
||||
|
||||
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM publications WHERE category = 'intl-journal-conf'").Scan(&summary.IntlPublications)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count intl publications: %w", err)
|
||||
}
|
||||
|
||||
err = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM standard_documents").Scan(&summary.StandardizationDocs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count standard documents: %w", err)
|
||||
}
|
||||
|
||||
err = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM patents").Scan(&summary.Patents)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count patents: %w", err)
|
||||
}
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
)
|
||||
|
||||
type LecturesRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewLecturesRepository(db *sql.DB) *LecturesRepository {
|
||||
return &LecturesRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
||||
semQuery := `
|
||||
SELECT id, year, term
|
||||
FROM semesters
|
||||
ORDER BY year DESC, CASE term WHEN 'Fall' THEN 1 WHEN 'Spring' THEN 2 ELSE 3 END, id DESC
|
||||
`
|
||||
semRows, err := r.db.QueryContext(ctx, semQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query semesters: %w", err)
|
||||
}
|
||||
defer semRows.Close()
|
||||
|
||||
semesters := make([]models.SemesterWithCourses, 0)
|
||||
semMap := make(map[int]int)
|
||||
|
||||
for semRows.Next() {
|
||||
var s models.SemesterWithCourses
|
||||
if err := semRows.Scan(&s.ID, &s.Year, &s.Term); err != nil {
|
||||
return nil, fmt.Errorf("scan semester: %w", err)
|
||||
}
|
||||
s.Courses = []models.Course{}
|
||||
semMap[s.ID] = len(semesters)
|
||||
semesters = append(semesters, s)
|
||||
}
|
||||
if err := semRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
courseQuery := `
|
||||
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
||||
FROM courses
|
||||
ORDER BY semester_id ASC, display_order ASC, id ASC
|
||||
`
|
||||
courseRows, err := r.db.QueryContext(ctx, courseQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query courses: %w", err)
|
||||
}
|
||||
defer courseRows.Close()
|
||||
|
||||
for courseRows.Next() {
|
||||
var c models.Course
|
||||
if err := courseRows.Scan(&c.ID, &c.SemesterID, &c.Code, &c.NameEn, &c.NameKr, &c.Note, &c.DisplayOrder); err != nil {
|
||||
return nil, fmt.Errorf("scan course: %w", err)
|
||||
}
|
||||
if idx, exists := semMap[c.SemesterID]; exists {
|
||||
semesters[idx].Courses = append(semesters[idx].Courses, c)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
)
|
||||
|
||||
type MembersRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewMembersRepository(db *sql.DB) *MembersRepository {
|
||||
return &MembersRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, error) {
|
||||
query := `
|
||||
SELECT id, name_ko, name_en, degree, affiliation, email, is_advisor, display_order, created_at
|
||||
FROM members
|
||||
ORDER BY display_order ASC, id ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make([]models.Member, 0)
|
||||
for rows.Next() {
|
||||
var m models.Member
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.NameKo, &m.NameEn, &m.Degree,
|
||||
&m.Affiliation, &m.Email, &m.IsAdvisor, &m.DisplayOrder, &m.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan member: %w", err)
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
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
|
||||
FROM alumni
|
||||
ORDER BY graduated_at DESC, id ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query alumni: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
alumni := make([]models.Alumnus, 0)
|
||||
for rows.Next() {
|
||||
var a models.Alumnus
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.NameKo, &a.NameEn, &a.Degree,
|
||||
&a.GraduatedAt, &a.Major, &a.CurrentPosition, &a.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan alumnus: %w", err)
|
||||
}
|
||||
alumni = append(alumni, a)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
)
|
||||
|
||||
type PublicationsRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPublicationsRepository(db *sql.DB) *PublicationsRepository {
|
||||
return &PublicationsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) GetPublications(ctx context.Context, category string) ([]models.Publication, error) {
|
||||
query := `
|
||||
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
|
||||
FROM publications
|
||||
`
|
||||
var args []any
|
||||
if category != "" {
|
||||
query += " WHERE category = ?"
|
||||
args = append(args, category)
|
||||
}
|
||||
query += " ORDER BY published_at DESC, id ASC"
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query publications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
pubs := make([]models.Publication, 0)
|
||||
for rows.Next() {
|
||||
var p models.Publication
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Category, &p.Title, &p.Authors,
|
||||
&p.Venue, &p.Volume, &p.PublishedAt, &p.KCI, &p.DOI, &p.IsHighlight, &p.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan publication: %w", err)
|
||||
}
|
||||
pubs = append(pubs, p)
|
||||
}
|
||||
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
|
||||
FROM publications
|
||||
WHERE is_highlight = 1
|
||||
ORDER BY published_at DESC, id ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query highlights: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
pubs := make([]models.Publication, 0)
|
||||
for rows.Next() {
|
||||
var p models.Publication
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Category, &p.Title, &p.Authors,
|
||||
&p.Venue, &p.Volume, &p.PublishedAt, &p.KCI, &p.DOI, &p.IsHighlight, &p.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan highlight: %w", err)
|
||||
}
|
||||
pubs = append(pubs, p)
|
||||
}
|
||||
return pubs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Patent, error) {
|
||||
query := `
|
||||
SELECT id, title, inventors, application_no, application_at, registration_no, registration_at, country, published_at, created_at
|
||||
FROM patents
|
||||
ORDER BY published_at DESC, id ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query patents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
patents := make([]models.Patent, 0)
|
||||
for rows.Next() {
|
||||
var p models.Patent
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Title, &p.Inventors, &p.ApplicationNo,
|
||||
&p.ApplicationAt, &p.RegistrationNo, &p.RegistrationAt, &p.Country, &p.PublishedAt, &p.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan patent: %w", err)
|
||||
}
|
||||
patents = append(patents, p)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
)
|
||||
|
||||
type StandardizationRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
|
||||
return &StandardizationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
||||
bodyQuery := `
|
||||
SELECT id, org, full_name, scope, period, role, display_order
|
||||
FROM standards_bodies
|
||||
ORDER BY display_order ASC, id ASC
|
||||
`
|
||||
bodyRows, err := r.db.QueryContext(ctx, bodyQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query standards bodies: %w", err)
|
||||
}
|
||||
defer bodyRows.Close()
|
||||
|
||||
bodies := make([]models.StandardsBodyWithProjects, 0)
|
||||
bodyMap := make(map[int]int)
|
||||
|
||||
for bodyRows.Next() {
|
||||
var b models.StandardsBodyWithProjects
|
||||
if err := bodyRows.Scan(&b.ID, &b.Org, &b.FullName, &b.Scope, &b.Period, &b.Role, &b.DisplayOrder); err != nil {
|
||||
return nil, fmt.Errorf("scan standards body: %w", err)
|
||||
}
|
||||
b.Projects = []models.StandardProjectGroup{}
|
||||
bodyMap[b.ID] = len(bodies)
|
||||
bodies = append(bodies, b)
|
||||
}
|
||||
if err := bodyRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
docQuery := `
|
||||
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
||||
FROM standard_documents
|
||||
ORDER BY body_id ASC, id ASC
|
||||
`
|
||||
docRows, err := r.db.QueryContext(ctx, docQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query standard documents: %w", err)
|
||||
}
|
||||
defer docRows.Close()
|
||||
|
||||
type projectKey struct {
|
||||
bodyID int
|
||||
wg string
|
||||
projectName string
|
||||
}
|
||||
projectMap := make(map[projectKey]int)
|
||||
|
||||
for docRows.Next() {
|
||||
var d models.StandardDocument
|
||||
if err := docRows.Scan(&d.ID, &d.BodyID, &d.WG, &d.ProjectName, &d.Title, &d.DocRef, &d.Status, &d.PublishedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan standard document: %w", err)
|
||||
}
|
||||
|
||||
bIdx, bodyExists := bodyMap[d.BodyID]
|
||||
if !bodyExists {
|
||||
continue
|
||||
}
|
||||
|
||||
key := projectKey{bodyID: d.BodyID, wg: d.WG, projectName: d.ProjectName}
|
||||
pIdx, projExists := projectMap[key]
|
||||
if !projExists {
|
||||
pIdx = len(bodies[bIdx].Projects)
|
||||
bodies[bIdx].Projects = append(bodies[bIdx].Projects, models.StandardProjectGroup{
|
||||
WG: d.WG,
|
||||
Name: d.ProjectName,
|
||||
Documents: []models.StandardDocument{},
|
||||
})
|
||||
projectMap[key] = pIdx
|
||||
}
|
||||
|
||||
bodies[bIdx].Projects[pIdx].Documents = append(bodies[bIdx].Projects[pIdx].Documents, d)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
allowOrigin := "*"
|
||||
if len(corsOrigins) > 0 && corsOrigins[0] != "" {
|
||||
allowOrigin = corsOrigins[0]
|
||||
}
|
||||
|
||||
// CORS Middleware
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", allowOrigin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Repositories
|
||||
homeRepo := repository.NewHomeRepository(db)
|
||||
membersRepo := repository.NewMembersRepository(db)
|
||||
pubsRepo := repository.NewPublicationsRepository(db)
|
||||
lecturesRepo := repository.NewLecturesRepository(db)
|
||||
standardsRepo := repository.NewStandardizationRepository(db)
|
||||
|
||||
// Handlers
|
||||
homeHandler := handlers.NewHomeHandler(homeRepo)
|
||||
membersHandler := handlers.NewMembersHandler(membersRepo)
|
||||
pubsHandler := handlers.NewPublicationsHandler(pubsRepo)
|
||||
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
||||
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
||||
|
||||
// API v1 Public Group (Read-only)
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
httpx.OK(c, gin.H{"status": "ok", "service": "anl-backend-api"})
|
||||
})
|
||||
|
||||
// Home
|
||||
v1.GET("/research-projects", homeHandler.GetResearchProjects)
|
||||
v1.GET("/research-areas", homeHandler.GetResearchAreas)
|
||||
v1.GET("/stats/summary", homeHandler.GetStatsSummary)
|
||||
|
||||
// Members & Alumni
|
||||
v1.GET("/members", membersHandler.GetMembers)
|
||||
v1.GET("/alumni", membersHandler.GetAlumni)
|
||||
|
||||
// Publications & Patents
|
||||
v1.GET("/publications", pubsHandler.GetPublications)
|
||||
v1.GET("/publications/highlights", pubsHandler.GetHighlights)
|
||||
v1.GET("/patents", pubsHandler.GetPatents)
|
||||
|
||||
// Lectures
|
||||
v1.GET("/semesters", lecturesHandler.GetSemesters)
|
||||
|
||||
// Standardization
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/router"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const testAdminToken = "test-secret-token"
|
||||
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
||||
database, err := db.Connect(tmpDB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.RunMigrations(database)
|
||||
require.NoError(t, err)
|
||||
|
||||
seedDir := filepath.Join("..", "..", "seed", "data")
|
||||
seedDatabase(t, database, seedDir)
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
||||
tx, err := database.BeginTx(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
defer tx.Rollback()
|
||||
|
||||
// 1. Projects
|
||||
pData, err := os.ReadFile(filepath.Join(seedDir, "research_projects.json"))
|
||||
require.NoError(t, err)
|
||||
var projects []struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Abstract string `json:"abstract"`
|
||||
Keywords []string `json:"keywords"`
|
||||
StandardsTrack *string `json:"standardsTrack"`
|
||||
StandardsOrg *string `json:"standardsOrg"`
|
||||
Period *string `json:"period"`
|
||||
Funder *string `json:"funder"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(pData, &projects))
|
||||
for _, p := range projects {
|
||||
kwJSON, _ := json.Marshal(p.Keywords)
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Slug, p.Title, p.Abstract, string(kwJSON), p.StandardsTrack, p.StandardsOrg, p.Period, p.Funder)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 2. Areas
|
||||
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
||||
require.NoError(t, err)
|
||||
var areas []struct {
|
||||
En string `json:"en"`
|
||||
Kr *string `json:"kr"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(aData, &areas))
|
||||
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 []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 idx, m := range members {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, 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 []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.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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 6. Patents
|
||||
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
||||
require.NoError(t, err)
|
||||
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 _, p := range patents {
|
||||
_, err := tx.Exec(`
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 7. Lectures
|
||||
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
||||
require.NoError(t, err)
|
||||
var semesters []struct {
|
||||
Term string `json:"term"`
|
||||
Year int `json:"year"`
|
||||
Courses []struct {
|
||||
Code string `json:"code"`
|
||||
En string `json:"en"`
|
||||
Note *string `json:"note"`
|
||||
} `json:"courses"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(lecData, &semesters))
|
||||
for _, s := range semesters {
|
||||
res, err := tx.Exec(`INSERT INTO semesters (year, term) VALUES (?, ?)`, s.Year, s.Term)
|
||||
require.NoError(t, err)
|
||||
semID, _ := res.LastInsertId()
|
||||
for cIdx, c := range s.Courses {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, semID, c.Code, c.En, nil, c.Note, cIdx+1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Standards
|
||||
stdData, err := os.ReadFile(filepath.Join(seedDir, "standards.json"))
|
||||
require.NoError(t, err)
|
||||
var standards []struct {
|
||||
Org string `json:"org"`
|
||||
Full string `json:"full"`
|
||||
Scope string `json:"scope"`
|
||||
Period string `json:"period"`
|
||||
Role *string `json:"role"`
|
||||
Projects []struct {
|
||||
WG string `json:"wg"`
|
||||
Name string `json:"name"`
|
||||
Documents []struct {
|
||||
Title string `json:"title"`
|
||||
Ref string `json:"ref"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *string `json:"publishedAt"`
|
||||
} `json:"documents"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(stdData, &standards))
|
||||
for bIdx, b := range standards {
|
||||
res, err := tx.Exec(`
|
||||
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, b.Org, b.Full, b.Scope, b.Period, b.Role, bIdx+1)
|
||||
require.NoError(t, err)
|
||||
bodyID, _ := res.LastInsertId()
|
||||
|
||||
for _, p := range b.Projects {
|
||||
for _, d := range p.Documents {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, bodyID, p.WG, p.Name, d.Title, d.Ref, d.Status, d.PublishedAt)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, tx.Commit())
|
||||
}
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Data T `json:"data"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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, 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 TestStaticServingAndRedirects(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. /intro 308 permanent redirect to /
|
||||
wIntro := doRequest(r, "GET", "/intro", nil, "")
|
||||
assert.Equal(t, http.StatusPermanentRedirect, wIntro.Code)
|
||||
assert.Equal(t, "/", wIntro.Header().Get("Location"))
|
||||
|
||||
// 2. Unknown API route returns 404 JSON (not HTML)
|
||||
wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wAPI404.Code)
|
||||
var apiErr map[string]any
|
||||
require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr))
|
||||
assert.Contains(t, apiErr, "error")
|
||||
|
||||
// 3. Create mock web directory to verify static HTML serving
|
||||
webDir := "./web"
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "test-page"), 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html>Home</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("<html>Test Page</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("<html>404 Not Found</html>"), 0644))
|
||||
defer os.RemoveAll(webDir)
|
||||
|
||||
// Test root URL /
|
||||
wRoot := doRequest(r, "GET", "/", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wRoot.Code)
|
||||
assert.Contains(t, wRoot.Body.String(), "<html>Home</html>")
|
||||
|
||||
// Test sub-route /test-page
|
||||
wTestPage := doRequest(r, "GET", "/test-page", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wTestPage.Code)
|
||||
assert.Contains(t, wTestPage.Body.String(), "<html>Test Page</html>")
|
||||
|
||||
// Test unknown page -> 404 HTML
|
||||
wUnknown := doRequest(r, "GET", "/completely-unknown-route", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wUnknown.Code)
|
||||
assert.Contains(t, wUnknown.Body.String(), "<html>404 Not Found</html>")
|
||||
}
|
||||
|
||||
func strconvItoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Exports curated data from refer_landing_page/content/*.ts and app/page.tsx into backend/seed/data/*.json.
|
||||
Ensures is_highlight is properly tagged on the 5 representative publications.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
CONTENT_DIR = os.path.join(REPO_ROOT, "refer_landing_page", "content")
|
||||
SEED_DATA_DIR = os.path.join(REPO_ROOT, "backend", "seed", "data")
|
||||
PAGE_TSX = os.path.join(REPO_ROOT, "refer_landing_page", "app", "page.tsx")
|
||||
|
||||
os.makedirs(SEED_DATA_DIR, exist_ok=True)
|
||||
|
||||
HIGHLIGHT_TITLES = {
|
||||
"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)",
|
||||
}
|
||||
|
||||
def clean_json_str(s):
|
||||
# Remove trailing commas before ] or }
|
||||
s = re.sub(r',\s*\]', ']', s)
|
||||
s = re.sub(r',\s*\}', '}', s)
|
||||
return s
|
||||
|
||||
def extract_json_array_from_ts(filepath, var_name):
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Match export const <var_name>[: Type] = [ ... ];
|
||||
pattern = rf'export\s+const\s+{var_name}(?:\s*:\s*[^=]+)?\s*=\s*(\[\s*[\s\S]*?\s*\]);'
|
||||
match = re.search(pattern, content)
|
||||
if not match:
|
||||
raise ValueError(f"Could not find array literal for '{var_name}' in {filepath}")
|
||||
|
||||
array_str = clean_json_str(match.group(1))
|
||||
return json.loads(array_str)
|
||||
|
||||
def main():
|
||||
print("=== Extracting TypeScript content to JSON for SQLite seeding ===")
|
||||
|
||||
# 1. Projects
|
||||
projects_file = os.path.join(CONTENT_DIR, "projects.ts")
|
||||
projects = extract_json_array_from_ts(projects_file, "researchProjects")
|
||||
with open(os.path.join(SEED_DATA_DIR, "research_projects.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(projects, f, indent=2, ensure_ascii=False)
|
||||
print(f"✓ Extracted {len(projects)} research projects")
|
||||
|
||||
# 2. Research Areas (from app/page.tsx)
|
||||
with open(PAGE_TSX, "r", encoding="utf-8") as f:
|
||||
page_src = f.read()
|
||||
areas_match = re.search(r'const\s+RESEARCH_AREAS(?:\s*:\s*[^=]+)?\s*=\s*(\[\s*[\s\S]*?\s*\]);', page_src)
|
||||
if not areas_match:
|
||||
raise ValueError("Could not find RESEARCH_AREAS in app/page.tsx")
|
||||
areas_str = clean_json_str(areas_match.group(1))
|
||||
areas_list = json.loads(areas_str)
|
||||
research_areas = [{"name_en": name, "name_kr": None, "display_order": i + 1} for i, name in enumerate(areas_list)]
|
||||
with open(os.path.join(SEED_DATA_DIR, "research_areas.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(research_areas, f, indent=2, ensure_ascii=False)
|
||||
print(f"✓ Extracted {len(research_areas)} research areas")
|
||||
|
||||
# 3. Members & Alumni
|
||||
members_file = os.path.join(CONTENT_DIR, "members.ts")
|
||||
active_members = extract_json_array_from_ts(members_file, "activeMembers")
|
||||
alumni = extract_json_array_from_ts(members_file, "alumni")
|
||||
|
||||
# Format active members with is_advisor
|
||||
formatted_members = []
|
||||
for i, m in enumerate(active_members):
|
||||
formatted_members.append({
|
||||
"name_ko": m["ko"],
|
||||
"name_en": m["en"],
|
||||
"degree": m["degree"],
|
||||
"affiliation": m.get("affiliation"),
|
||||
"email": "sjkoh@knu.ac.kr" if m["degree"] == "Professor" else None,
|
||||
"is_advisor": m["degree"] == "Professor",
|
||||
"display_order": i + 1,
|
||||
})
|
||||
with open(os.path.join(SEED_DATA_DIR, "members.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(formatted_members, f, indent=2, ensure_ascii=False)
|
||||
print(f"✓ Extracted {len(formatted_members)} active members")
|
||||
|
||||
formatted_alumni = []
|
||||
for a in alumni:
|
||||
formatted_alumni.append({
|
||||
"name_ko": a["ko"],
|
||||
"name_en": a["en"],
|
||||
"degree": a["degree"],
|
||||
"graduated_at": a["graduatedAt"],
|
||||
"major": a.get("major"),
|
||||
"current_position": a.get("currentPosition"),
|
||||
})
|
||||
with open(os.path.join(SEED_DATA_DIR, "alumni.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(formatted_alumni, f, indent=2, ensure_ascii=False)
|
||||
print(f"✓ Extracted {len(formatted_alumni)} alumni")
|
||||
|
||||
# 4. Publications & Patents
|
||||
pubs_file = os.path.join(CONTENT_DIR, "publications.ts")
|
||||
raw_pubs = extract_json_array_from_ts(pubs_file, "publications")
|
||||
raw_patents = extract_json_array_from_ts(pubs_file, "patents")
|
||||
|
||||
matched_highlights = []
|
||||
formatted_pubs = []
|
||||
for p in raw_pubs:
|
||||
title_clean = p["title"].strip()
|
||||
is_hl = title_clean in HIGHLIGHT_TITLES
|
||||
if is_hl:
|
||||
matched_highlights.append(title_clean)
|
||||
formatted_pubs.append({
|
||||
"category": p["category"],
|
||||
"title": p["title"],
|
||||
"authors": p.get("authors"),
|
||||
"venue": p["venue"],
|
||||
"volume": p.get("volume"),
|
||||
"published_at": p["publishedAt"],
|
||||
"kci": bool(p.get("kci", False)),
|
||||
"doi": p.get("doi"),
|
||||
"is_highlight": is_hl,
|
||||
})
|
||||
|
||||
if len(matched_highlights) != 5:
|
||||
raise ValueError(f"Highlight matching error: expected exactly 5 matched highlights, found {len(matched_highlights)}: {matched_highlights}")
|
||||
|
||||
with open(os.path.join(SEED_DATA_DIR, "publications.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(formatted_pubs, f, indent=2, ensure_ascii=False)
|
||||
print(f"✓ Extracted {len(formatted_pubs)} publications (exactly {len(matched_highlights)} highlights tagged)")
|
||||
|
||||
formatted_patents = []
|
||||
for pat in raw_patents:
|
||||
formatted_patents.append({
|
||||
"title": pat["title"],
|
||||
"inventors": pat["inventors"],
|
||||
"application_no": pat["applicationNo"],
|
||||
"application_at": pat["applicationAt"],
|
||||
"registration_no": pat.get("registrationNo"),
|
||||
"registration_at": pat.get("registrationAt"),
|
||||
"country": pat["country"],
|
||||
"published_at": pat["publishedAt"],
|
||||
})
|
||||
with open(os.path.join(SEED_DATA_DIR, "patents.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(formatted_patents, f, indent=2, ensure_ascii=False)
|
||||
print(f"✓ Extracted {len(formatted_patents)} patents")
|
||||
|
||||
# 5. Lectures (Semesters & Courses)
|
||||
lectures_file = os.path.join(CONTENT_DIR, "lectures.ts")
|
||||
raw_semesters = extract_json_array_from_ts(lectures_file, "semesters")
|
||||
with open(os.path.join(SEED_DATA_DIR, "lectures.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(raw_semesters, f, indent=2, ensure_ascii=False)
|
||||
|
||||
total_courses = sum(len(s.get("courses", [])) for s in raw_semesters)
|
||||
print(f"✓ Extracted {len(raw_semesters)} semesters with {total_courses} courses")
|
||||
|
||||
# 6. Standardization
|
||||
standards_file = os.path.join(CONTENT_DIR, "standards.ts")
|
||||
raw_standards = extract_json_array_from_ts(standards_file, "standardsBodies")
|
||||
with open(os.path.join(SEED_DATA_DIR, "standards.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(raw_standards, f, indent=2, ensure_ascii=False)
|
||||
|
||||
total_docs = sum(
|
||||
sum(len(p.get("documents", [])) for p in b.get("projects", []))
|
||||
for b in raw_standards
|
||||
)
|
||||
print(f"✓ Extracted {len(raw_standards)} standards bodies with {total_docs} documents")
|
||||
|
||||
print("=== Extraction completed successfully ===")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,482 @@
|
||||
[
|
||||
{
|
||||
"name_ko": "김동주",
|
||||
"name_en": "Dongju Kim",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2026-08",
|
||||
"major": "Computer Science and Engineering",
|
||||
"current_position": "He is now with Daegu Catholic University (대구가톨릭대학교) as Professor"
|
||||
},
|
||||
{
|
||||
"name_ko": "황정미",
|
||||
"name_en": "Jung-Mi Hwang",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2026-08",
|
||||
"major": "Information Science",
|
||||
"current_position": "She is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "배드로",
|
||||
"name_en": "Dro Bae",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2026-02",
|
||||
"major": "Industrial Engineering",
|
||||
"current_position": "He is now with SK AX"
|
||||
},
|
||||
{
|
||||
"name_ko": "권세기",
|
||||
"name_en": "Se-Gi Kwon",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2026-02",
|
||||
"major": "Information Security",
|
||||
"current_position": "He is now with J Solution (제이솔루션) as CEO"
|
||||
},
|
||||
{
|
||||
"name_ko": "조세현",
|
||||
"name_en": "Se-Hyun Cho",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2025-08",
|
||||
"major": "Information Security",
|
||||
"current_position": "He is now with Cyber Security Center"
|
||||
},
|
||||
{
|
||||
"name_ko": "김윤성",
|
||||
"name_en": "Yun-Seong Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2025-08",
|
||||
"major": "Data Convergence Computing",
|
||||
"current_position": "He is now with SL (에스엘)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김수진",
|
||||
"name_en": "Su-Jin Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2025-08",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김상태",
|
||||
"name_en": "Sang-Tae Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2025-08",
|
||||
"major": "Industrial Engineering",
|
||||
"current_position": "He is now with Oh-Sung Electronis (오성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김규범",
|
||||
"name_en": "Gyu-Bum Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2025-08",
|
||||
"major": "Computer Science and Engineering",
|
||||
"current_position": "He is now with Korea Real Estate Board (한국부동산원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "정중화",
|
||||
"name_en": "Joong-Hwa Jung",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2025-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Lychee AI Coding Academy (리치AI코딩학원) as CEO"
|
||||
},
|
||||
{
|
||||
"name_ko": "임도현",
|
||||
"name_en": "Dohyeon Lim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2025-02",
|
||||
"major": "Computer Science and Engineering",
|
||||
"current_position": "He is now with TakenSoft (테이큰소프트)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김소용",
|
||||
"name_en": "So-Yong Kim",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2024-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with Catholic University of Leuven (UCLouvain) in Belgium"
|
||||
},
|
||||
{
|
||||
"name_ko": "김민지",
|
||||
"name_en": "Min-Ji Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2024-02",
|
||||
"major": null,
|
||||
"current_position": "She is now with LG Electronics"
|
||||
},
|
||||
{
|
||||
"name_ko": "안은지",
|
||||
"name_en": "Eun-Ji Ahn",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2024-02",
|
||||
"major": null,
|
||||
"current_position": "S is now with National Security Research Institute (국가보안연구소)"
|
||||
},
|
||||
{
|
||||
"name_ko": "오동규",
|
||||
"name_en": "Dong-Kyu Oh",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2024-02",
|
||||
"major": "Industrial Engineering",
|
||||
"current_position": "He is now with Human-Plus (휴먼플러스)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김재성",
|
||||
"name_en": "Jae-Seong Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2023-08",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "최동규",
|
||||
"name_en": "Dong-Kyu Choi",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2023-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with ISL in KNU"
|
||||
},
|
||||
{
|
||||
"name_ko": "최진원",
|
||||
"name_en": "Jin-Won Choi",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2023-02",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김지은",
|
||||
"name_en": "Ji-Eun Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2023-02",
|
||||
"major": "Information Science",
|
||||
"current_position": "She is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김철민",
|
||||
"name_en": "Cheol-Min Kim",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2022-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with KETI (한국전자기술연구원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김경식",
|
||||
"name_en": "Kyung-Sik Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2022-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Hyundai Autoever (현대오토에버)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김근수",
|
||||
"name_en": "Keun-Soo Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2022-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Shinhan Bank (신한은행)"
|
||||
},
|
||||
{
|
||||
"name_ko": "정성우",
|
||||
"name_en": "Seong-Woo Jeong",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2022-02",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "최민철",
|
||||
"name_en": "Min-Cheol Choi",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2022-02",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "장준희",
|
||||
"name_en": "Jun-Hee Jang",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2021-08",
|
||||
"major": "Computer Science and Engineering",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "Muhammad Hafidh Firmansyah",
|
||||
"name_en": "Muhammad Hafidh Firmansyah",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2021-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with State Polytechnic University of Jember in Indonesia"
|
||||
},
|
||||
{
|
||||
"name_ko": "장강민",
|
||||
"name_en": "Kang-Min Jang",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2021-02",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "이홍근",
|
||||
"name_en": "Hong-Keun Lee",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2021-02",
|
||||
"major": "Information Science",
|
||||
"current_position": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"name_ko": "남혜빈",
|
||||
"name_en": "Hyebeen Nam",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2021-02",
|
||||
"major": null,
|
||||
"current_position": "She is now with ISL in KNU"
|
||||
},
|
||||
{
|
||||
"name_ko": "최낙중",
|
||||
"name_en": "Nak-Jung Choi",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2020-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Agency for Defense Development (국방과학연구소)"
|
||||
},
|
||||
{
|
||||
"name_ko": "정민우",
|
||||
"name_en": "Min-Woo Jung",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2019-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with LG Electronics (LG전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "강형우",
|
||||
"name_en": "Hyung-Woo Kang",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2018-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "최예찬",
|
||||
"name_en": "Ye-Chan Choi",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2018-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with LG-CNS"
|
||||
},
|
||||
{
|
||||
"name_ko": "최상일",
|
||||
"name_en": "Sang-Il Choi",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2017-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Daegu Catholic University (대구가톨릭대학교) as a professor"
|
||||
},
|
||||
{
|
||||
"name_ko": "박진호",
|
||||
"name_en": "Jin-Ho Park",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2016-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with BiThumb (빗썸코리아)"
|
||||
},
|
||||
{
|
||||
"name_ko": "석성윤",
|
||||
"name_en": "Sung-Yoon Seok",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2016-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김진규",
|
||||
"name_en": "Jin-Kyu Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2016-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김재철",
|
||||
"name_en": "Jae-Cheol Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2016-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김지인",
|
||||
"name_en": "Ji-In Kim",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2016-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with FAM Tech. (팸텍) as CEO"
|
||||
},
|
||||
{
|
||||
"name_ko": "김우주",
|
||||
"name_en": "Woo-Ju Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2016-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with ZCube (지큐브)"
|
||||
},
|
||||
{
|
||||
"name_ko": "박정섭",
|
||||
"name_en": "Jung-Sub Park",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2015-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "최종명",
|
||||
"name_en": "Jong-Myoung Choi",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2015-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with NP Communications"
|
||||
},
|
||||
{
|
||||
"name_ko": "이종관",
|
||||
"name_en": "Jong-Kwan Lee",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2014-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Hanwha Ocean (한화오션)"
|
||||
},
|
||||
{
|
||||
"name_ko": "이상헌",
|
||||
"name_en": "Sang-Hun Lee",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2014-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Inno Wireless"
|
||||
},
|
||||
{
|
||||
"name_ko": "민경욱",
|
||||
"name_en": "Kyeong-Wook Min",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2013-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김상헌",
|
||||
"name_en": "Sang-Heon Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2013-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "Moneeb Gohar",
|
||||
"name_en": "Moneeb Gohar",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2012-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with Department of Computer Science, Bahria University (at Islamabad) in Pakistan"
|
||||
},
|
||||
{
|
||||
"name_ko": "박재완",
|
||||
"name_en": "Jae-Wan Park",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2012-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Ministry of Justice (Immigration Information Center, 법무부)"
|
||||
},
|
||||
{
|
||||
"name_ko": "이재경",
|
||||
"name_en": "Jae-Kyoung Lee",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2012-02",
|
||||
"major": null,
|
||||
"current_position": "She is now with Korea Transportation Satefy Authority (한국교통안전공단)"
|
||||
},
|
||||
{
|
||||
"name_ko": "하원기",
|
||||
"name_en": "Won-Ki Ha",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2012-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김근희",
|
||||
"name_en": "Keun-Hee Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2012-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"current_position": "She is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "권순홍",
|
||||
"name_en": "Soon-Hong Kwon",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2010-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "김동필",
|
||||
"name_en": "Dong-Phil Kim",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2009-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with National Security Research Institute (국가보안연구소)"
|
||||
},
|
||||
{
|
||||
"name_ko": "최린",
|
||||
"name_en": "Lin Cui",
|
||||
"degree": "PhD",
|
||||
"graduated_at": "2009-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Tianjin University of Technology and Education in China"
|
||||
},
|
||||
{
|
||||
"name_ko": "이동화",
|
||||
"name_en": "Dong-Hwa Lee",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2009-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "박재성",
|
||||
"name_en": "Jae-Sung Park",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2008-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with Gom&Company (곰앤컴퍼니)"
|
||||
},
|
||||
{
|
||||
"name_ko": "주수경",
|
||||
"name_en": "Su-Kyoung Ju",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2008-02",
|
||||
"major": null,
|
||||
"current_position": "She is now with Youngjin Univ. (영진전문대학교)"
|
||||
},
|
||||
{
|
||||
"name_ko": "윤성식",
|
||||
"name_en": "Sung-Shik Yoon",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2007-08",
|
||||
"major": null,
|
||||
"current_position": "He is now with Jo-Il Textile Processing Company (조일가공) as CEO"
|
||||
},
|
||||
{
|
||||
"name_ko": "김상태",
|
||||
"name_en": "Sang-Tae Kim",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2007-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with LG Electronics (LG전자)"
|
||||
},
|
||||
{
|
||||
"name_ko": "하종식",
|
||||
"name_en": "Jong-Shik Ha",
|
||||
"degree": "MS",
|
||||
"graduated_at": "2007-02",
|
||||
"major": null,
|
||||
"current_position": "He is now with Samsung Electronics (삼성전자)"
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,4 @@
|
||||
// AUTO-GENERATED by scripts/extract/generate_all.py — DO NOT EDIT BY HAND
|
||||
import { Semester } from "./types";
|
||||
|
||||
export const semesters: Semester[] = [
|
||||
[
|
||||
{
|
||||
"term": "Spring",
|
||||
"year": 2026,
|
||||
@@ -801,4 +798,4 @@ export const semesters: Semester[] = [
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"name_ko": "고석주",
|
||||
"name_en": "Seok-Joo Koh",
|
||||
"degree": "Professor",
|
||||
"affiliation": null,
|
||||
"email": "sjkoh@knu.ac.kr",
|
||||
"is_advisor": true,
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"name_ko": "최동규",
|
||||
"name_en": "Dong-Kyu Choi",
|
||||
"degree": "PhD",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"name_ko": "남혜빈",
|
||||
"name_en": "Hye-Been Nam",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"name_ko": "임도현",
|
||||
"name_en": "Dohyeon Lim",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 4
|
||||
},
|
||||
{
|
||||
"name_ko": "최준혁",
|
||||
"name_en": "Jun-Hyeok Choi",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 5
|
||||
},
|
||||
{
|
||||
"name_ko": "신광선",
|
||||
"name_en": "Gwang-Seon Shin",
|
||||
"degree": "MSCandidate",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 6
|
||||
},
|
||||
{
|
||||
"name_ko": "이환웅",
|
||||
"name_en": "Hwan-Woong Lee",
|
||||
"degree": "MSStudent",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 7
|
||||
},
|
||||
{
|
||||
"name_ko": "Hoang Anh Dang",
|
||||
"name_en": "Hoang Anh Dang",
|
||||
"degree": "MSStudent",
|
||||
"affiliation": null,
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 8
|
||||
},
|
||||
{
|
||||
"name_ko": "김민희",
|
||||
"name_en": "Minhee Kim",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": "School of Computer Science and Engineering",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 9
|
||||
},
|
||||
{
|
||||
"name_ko": "김근솔",
|
||||
"name_en": "Keun-Sol Kim",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": "Department of Information Security",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 10
|
||||
},
|
||||
{
|
||||
"name_ko": "민성준",
|
||||
"name_en": "Sung-Jun Min",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": "Department of Information Science",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 11
|
||||
},
|
||||
{
|
||||
"name_ko": "최진원",
|
||||
"name_en": "Jin-Won Choi",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": "Department of Science and Technology",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 12
|
||||
},
|
||||
{
|
||||
"name_ko": "정기수",
|
||||
"name_en": "Ki-Soo Jung",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": "Department of Science and Technology",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 13
|
||||
},
|
||||
{
|
||||
"name_ko": "이미주",
|
||||
"name_en": "Mi-Joo Lee",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": "Department of ICT Convergence",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 14
|
||||
},
|
||||
{
|
||||
"name_ko": "박유나",
|
||||
"name_en": "Yunah Park",
|
||||
"degree": "MSCandidate",
|
||||
"affiliation": "Department of Information Security",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 15
|
||||
},
|
||||
{
|
||||
"name_ko": "박채영",
|
||||
"name_en": "Chae-Young Park",
|
||||
"degree": "MSCandidate",
|
||||
"affiliation": "Department of Information Science",
|
||||
"email": null,
|
||||
"is_advisor": false,
|
||||
"display_order": 16
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,752 @@
|
||||
[
|
||||
{
|
||||
"title": "국내 지역별 태양광 발전량 및 전력 단가 예측 웹 서비스 제공 장치 및 그 방법",
|
||||
"inventors": "고석주, 김경훈, 김나현, 정수인, 홍일표",
|
||||
"application_no": "2025-0200788",
|
||||
"application_at": "2025-12-16",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2025-12"
|
||||
},
|
||||
{
|
||||
"title": "사물인터넷 네트워크 환경에서 QUIC 기반으로 CoAP 메시지를 중계하는 시스템",
|
||||
"inventors": "고석주, 정중화, 남혜빈, 최동규, 김민지",
|
||||
"application_no": "2023-0180751",
|
||||
"application_at": "2023-12-13",
|
||||
"registration_no": "2963109",
|
||||
"registration_at": "2026-05-06",
|
||||
"country": "한국",
|
||||
"published_at": "2023-12"
|
||||
},
|
||||
{
|
||||
"title": "엣지 컴퓨팅을 위한 사물인터넷 서비스 시스템",
|
||||
"inventors": "고석주, 정중화, 남혜빈, 최동규",
|
||||
"application_no": "2023-0161365",
|
||||
"application_at": "2023-11-20",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2023-11"
|
||||
},
|
||||
{
|
||||
"title": "무선 네트워크 환경에서 핸드오버 및 커넥션 마이그레이션을 지원하는 장치 및 방법",
|
||||
"inventors": "고석주, 김소용, 모닙 고하르, 최동규",
|
||||
"application_no": "2023-0063455",
|
||||
"application_at": "2023-05-17",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2023-05"
|
||||
},
|
||||
{
|
||||
"title": "제한된 통신 환경에서 브로커 서버를 이용한 스마트 약상자 제어 시스템 및 방법",
|
||||
"inventors": "고석주, 김근수, 김철민, 김경식, 나재욱, 박진호",
|
||||
"application_no": "2020-0164156",
|
||||
"application_at": "2020-11-30",
|
||||
"registration_no": "2384614",
|
||||
"registration_at": "2022-04-05",
|
||||
"country": "한국",
|
||||
"published_at": "2020-11"
|
||||
},
|
||||
{
|
||||
"title": "단방향 가시광 통신 환경에서 데이터 전송 신뢰성 개선을 위한 방법 및 이를 이용한 하이브리드",
|
||||
"inventors": "고석주, 김소용, 최동규, 남혜빈, 정중화, 김창묵",
|
||||
"application_no": "2020-0164152",
|
||||
"application_at": "2020-11-30",
|
||||
"registration_no": "2420614",
|
||||
"registration_at": "2022-07-08",
|
||||
"country": "한국",
|
||||
"published_at": "2020-11"
|
||||
},
|
||||
{
|
||||
"title": "사물 인터넷 서비스를 제공하기 위한 QUIC-Proxy를 이용한 데이터 전달 방법 및 장치",
|
||||
"inventors": "고석주, 김소용, 최동규, 남혜빈, 정중화",
|
||||
"application_no": "2020-0164141",
|
||||
"application_at": "2020-11-30",
|
||||
"registration_no": "2345473",
|
||||
"registration_at": "2021-12-27",
|
||||
"country": "한국",
|
||||
"published_at": "2020-11"
|
||||
},
|
||||
{
|
||||
"title": "금연구역 관리 서비스 방법 및 시스템",
|
||||
"inventors": "고석주, 이정우, 이용호, 권오상, 정서영, 김경식",
|
||||
"application_no": "2020-0101365",
|
||||
"application_at": "2020-08-12",
|
||||
"registration_no": "2375686",
|
||||
"registration_at": "2022-03-14",
|
||||
"country": "한국",
|
||||
"published_at": "2020-08"
|
||||
},
|
||||
{
|
||||
"title": "사물 인터넷 환경에서 CoAP 기반의 데이터 스트리밍 방법 및 통신 시스템",
|
||||
"inventors": "고석주, 정중화, 최동규, 남혜빈, 이채현",
|
||||
"application_no": "2020-0094058",
|
||||
"application_at": "2020-07-28",
|
||||
"registration_no": "2375703",
|
||||
"registration_at": "2022-03-14",
|
||||
"country": "한국",
|
||||
"published_at": "2020-07"
|
||||
},
|
||||
{
|
||||
"title": "인공지능 기반 적외선 카메라 센싱 시스템 및 방법",
|
||||
"inventors": "고석주, 홍성기, 김희원, 박명훈, 권민철",
|
||||
"application_no": "2019-0174964",
|
||||
"application_at": "2019-12-26",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2019-12"
|
||||
},
|
||||
{
|
||||
"title": "폭력 행위 관리 시스템 및 방법",
|
||||
"inventors": "고석주, 김동욱, 윤서원, 구영준, 성경화, 경예지",
|
||||
"application_no": "2019-0122161",
|
||||
"application_at": "2019-10-02",
|
||||
"registration_no": "2264275",
|
||||
"registration_at": "2021-06-07",
|
||||
"country": "한국",
|
||||
"published_at": "2019-10"
|
||||
},
|
||||
{
|
||||
"title": "차량 인포테인먼트 관리 시스템",
|
||||
"inventors": "고석주, 최동규, 정중화, 남혜빈, 손종명",
|
||||
"application_no": "2019-0120044",
|
||||
"application_at": "2019-09-27",
|
||||
"registration_no": "2410024",
|
||||
"registration_at": "2022-06-13",
|
||||
"country": "한국",
|
||||
"published_at": "2019-09"
|
||||
},
|
||||
{
|
||||
"title": "차량 인포테인먼트 마스터 장치 및 이를 포함하는 차량 인포테인먼트 통합 관리 시스템",
|
||||
"inventors": "고석주, 최동규, 정중화, 남혜빈, 신호경",
|
||||
"application_no": "2019-0059551",
|
||||
"application_at": "2019-05-21",
|
||||
"registration_no": "2251310",
|
||||
"registration_at": "2021-05-06",
|
||||
"country": "한국",
|
||||
"published_at": "2019-05"
|
||||
},
|
||||
{
|
||||
"title": "CoAP 기반의 센싱 정보 스트리밍 방법",
|
||||
"inventors": "고석주, 최동규, 정중화, 남혜빈",
|
||||
"application_no": "2018-0168580",
|
||||
"application_at": "2018-12-24",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-12"
|
||||
},
|
||||
{
|
||||
"title": "이미지에서 기계학습 기법을 활용한 특정 부품영역 탐지 방법",
|
||||
"inventors": "고석주, 김대기, 김동인, 방종원, 우진철, 정중화",
|
||||
"application_no": "2018-0167974",
|
||||
"application_at": "2018-12-21",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-12"
|
||||
},
|
||||
{
|
||||
"title": "블록체인 기반의 재정장부 관리 시스템",
|
||||
"inventors": "고석주, 신승민, 서상민, 송동훈, 최효선, 그레고즈 리뼤시치",
|
||||
"application_no": "2018-0167970",
|
||||
"application_at": "2018-12-21",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-12"
|
||||
},
|
||||
{
|
||||
"title": "블록체인을 이용한 게임정보 처리 방법",
|
||||
"inventors": "고석주, 정재훈, 서창호, 노경환, 원응호",
|
||||
"application_no": "2018-0167951",
|
||||
"application_at": "2018-12-21",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-12"
|
||||
},
|
||||
{
|
||||
"title": "가시광 통신을 이용한 서비스 제공 시스템 및 그 방법",
|
||||
"inventors": "고석주, 김철민, 김소용, 모닙고하르",
|
||||
"application_no": "2018-0155013",
|
||||
"application_at": "2018-12-05",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-12"
|
||||
},
|
||||
{
|
||||
"title": "차량 인포테인먼트 시스템",
|
||||
"inventors": "고석주, 최동규, 정중화, 정민우",
|
||||
"application_no": "2018-0083515",
|
||||
"application_at": "2018-07-18",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-07"
|
||||
},
|
||||
{
|
||||
"title": "CoAP 기반의 사물인터넷 기술을 이용한 센서 관리 방법 및 이를 이용한 시스템",
|
||||
"inventors": "고석주, 최동규, 정중화, 정민우",
|
||||
"application_no": "2018-0082485",
|
||||
"application_at": "2018-07-16",
|
||||
"registration_no": "2071974",
|
||||
"registration_at": "2020-01-23",
|
||||
"country": "한국",
|
||||
"published_at": "2018-07"
|
||||
},
|
||||
{
|
||||
"title": "가시광 통신을 이용한 무선 인터넷 비밀번호 관리 시스템 및 방법",
|
||||
"inventors": "김소용, 김철민, 고석주",
|
||||
"application_no": "2018-0082463",
|
||||
"application_at": "2018-07-16",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-07"
|
||||
},
|
||||
{
|
||||
"title": "시각 장애인을 위한 가시광 통신 기반의 교통 신호 전달 시스템 및 방법",
|
||||
"inventors": "권경동, 금동우, 황지영, 채윤창, 김철민, 김소용, 고석주",
|
||||
"application_no": "2018-0079319",
|
||||
"application_at": "2018-07-09",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2018-07"
|
||||
},
|
||||
{
|
||||
"title": "데이터 전달 장치, 방법과 그를 이용한 사물 인터넷 시스템, 데이터 전달 방법을 실행하기 위한 프로그램이",
|
||||
"inventors": "고석주, 최동규, 정중화",
|
||||
"application_no": "2017-0153908",
|
||||
"application_at": "2017-11-17",
|
||||
"registration_no": "1969652",
|
||||
"registration_at": "2019-04-10",
|
||||
"country": "한국",
|
||||
"published_at": "2017-11"
|
||||
},
|
||||
{
|
||||
"title": "분산된 게시-구독 기법을 이용한 CoAP 기반 사물 인터넷 시스템의 작동 방법",
|
||||
"inventors": "고석주, 정중화, 최동규",
|
||||
"application_no": "2017-0153357",
|
||||
"application_at": "2017-11-16",
|
||||
"registration_no": "2031726",
|
||||
"registration_at": "2019-10-07",
|
||||
"country": "한국",
|
||||
"published_at": "2017-11"
|
||||
},
|
||||
{
|
||||
"title": "비콘 기반 식당 정보 서비스 시스템 및 방법",
|
||||
"inventors": "고석주, 안창준, 김혜경, 송명근, 최예찬, 허동",
|
||||
"application_no": "2017-0087265",
|
||||
"application_at": "2017-07-10",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2017-07"
|
||||
},
|
||||
{
|
||||
"title": "사물 인터넷 네트워크에서의 모바일 단말 이동성 제어 장치 및 방법",
|
||||
"inventors": "고석주, 최상일",
|
||||
"application_no": "2017-0064039",
|
||||
"application_at": "2017-05-24",
|
||||
"registration_no": "1847081",
|
||||
"registration_at": "2018-04-03",
|
||||
"country": "한국",
|
||||
"published_at": "2017-05"
|
||||
},
|
||||
{
|
||||
"title": "데이터 전달 장치, 방법 및 그를 이용한 사물인터넷 시스템",
|
||||
"inventors": "고석주, 정중화, 강형우, 최동규",
|
||||
"application_no": "2016-0175882",
|
||||
"application_at": "2016-12-21",
|
||||
"registration_no": "1972470",
|
||||
"registration_at": "2019-04-19",
|
||||
"country": "한국",
|
||||
"published_at": "2016-12"
|
||||
},
|
||||
{
|
||||
"title": "저전력 근거리 통신을 이용한 개인 정보 교환 방법 및 시스템, 이를 수행하기 위한 기록매체",
|
||||
"inventors": "고석주, 김지인, 김지희, 김송아, 박지혜, 이승일, 서대화",
|
||||
"application_no": "2016-0157411",
|
||||
"application_at": "2016-11-24",
|
||||
"registration_no": "1784309",
|
||||
"registration_at": "2017-09-27",
|
||||
"country": "한국",
|
||||
"published_at": "2016-11"
|
||||
},
|
||||
{
|
||||
"title": "가시광 통신 기기 관리 방법 및 장치 (with 유양디앤유)",
|
||||
"inventors": "김상옥, 유병오, 윤상호, 노승완, 고석주, 최상일",
|
||||
"application_no": "2016-0157606",
|
||||
"application_at": "2016-11-24",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2016-11"
|
||||
},
|
||||
{
|
||||
"title": "가시광 통신 방법 및 장치 (with 유양디앤유)",
|
||||
"inventors": "김상옥, 유병오, 윤상호, 노승완, 고석주, 최상일",
|
||||
"application_no": "2016-0157576",
|
||||
"application_at": "2016-11-24",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2016-11"
|
||||
},
|
||||
{
|
||||
"title": "이동 단말 및 관리 서버의 제어 방법",
|
||||
"inventors": "고석주, 최상일",
|
||||
"application_no": "2016-0051126",
|
||||
"application_at": "2016-04-26",
|
||||
"registration_no": "1836116",
|
||||
"registration_at": "2018-03-02",
|
||||
"country": "한국",
|
||||
"published_at": "2016-04"
|
||||
},
|
||||
{
|
||||
"title": "무선 통신을 이용한 범용 안내 서비스 제공 방법 및 시스템",
|
||||
"inventors": "고석주, 장호영, 이종훈, 박찬석, 서민영",
|
||||
"application_no": "2016-0013614",
|
||||
"application_at": "2016-02-03",
|
||||
"registration_no": "1716661",
|
||||
"registration_at": "2017-03-09",
|
||||
"country": "한국",
|
||||
"published_at": "2016-02"
|
||||
},
|
||||
{
|
||||
"title": "디지털 도어락 시스템 및 그 제어 방법",
|
||||
"inventors": "고석주, 권다영, 노혜성, 이수정",
|
||||
"application_no": "2015-0163735",
|
||||
"application_at": "2015-11-23",
|
||||
"registration_no": "1814555",
|
||||
"registration_at": "2017-12-27",
|
||||
"country": "한국",
|
||||
"published_at": "2015-11"
|
||||
},
|
||||
{
|
||||
"title": "무인 지상차량을 이용한 주차차량 관리 시스템",
|
||||
"inventors": "김인한, 권혜련, 이은섭, 고석주",
|
||||
"application_no": "2015-0008735",
|
||||
"application_at": "2015-01-19",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2015-01"
|
||||
},
|
||||
{
|
||||
"title": "ESL 신호를 이용하는 측위 방법, 이를 수행하기 위한 기록 매체, 단말기 및 시스템",
|
||||
"inventors": "고석주, 김지인, 이민형, 김종근, 박지수, 조정근, 이승일, 서대화",
|
||||
"application_no": "2014-0180037",
|
||||
"application_at": "2014-12-15",
|
||||
"registration_no": "1596320",
|
||||
"registration_at": "2016-02-16",
|
||||
"country": "한국",
|
||||
"published_at": "2014-12"
|
||||
},
|
||||
{
|
||||
"title": "ESL 신호를 이용하여 위치 기반 서비스를 제공하는 스마트 장치",
|
||||
"inventors": "고석주, 김지인, 이민형, 김종근, 박지수, 조정근, 이승일, 서대화",
|
||||
"application_no": "2014-0180036",
|
||||
"application_at": "2014-12-15",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2014-12"
|
||||
},
|
||||
{
|
||||
"title": "3차원 형상 복원을 위한 볼륨 카빙 장치 및 그 방법",
|
||||
"inventors": "고석주, 하태윤, 서대화, 정귀영, 이상은, 김지인, 김한별",
|
||||
"application_no": "2014-0167063",
|
||||
"application_at": "2014-11-27",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2014-11"
|
||||
},
|
||||
{
|
||||
"title": "립모션 기기를 이용한 수화 번역 시스템 및 그 방법",
|
||||
"inventors": "고석주, 조재현, 서대화, 이동훈, 이상은, 김지인, 하대규",
|
||||
"application_no": "2014-0166166",
|
||||
"application_at": "2014-11-26",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2014-11"
|
||||
},
|
||||
{
|
||||
"title": "립모션 기기의 수화 번역 정확도 향상을 위한 수화 번역 시스템 및 그 방법",
|
||||
"inventors": "고석주, 조재현, 서대화, 이동훈, 이상은, 김지인, 하대규",
|
||||
"application_no": "2014-0166165",
|
||||
"application_at": "2014-11-26",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2014-11"
|
||||
},
|
||||
{
|
||||
"title": "저전력 근거리 통신을 이용한 개인 정보 교환 방법 및 시스템, 이를 수행하기 위한 기록매체",
|
||||
"inventors": "고석주, 김지희, 서대화, 박지혜, 이승일, 김지인, 김송아",
|
||||
"application_no": "2014-0164802",
|
||||
"application_at": "2014-11-24",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2014-11"
|
||||
},
|
||||
{
|
||||
"title": "차량 분산 방법 및 장치, 이를 수행하기 위한 기록매체",
|
||||
"inventors": "고석주, 김지인, 엄진욱, 김민규, 홍석진, 배정규, 서대화",
|
||||
"application_no": "2014-0161975",
|
||||
"application_at": "2014-11-19",
|
||||
"registration_no": "1612047",
|
||||
"registration_at": "2016-04-06",
|
||||
"country": "한국",
|
||||
"published_at": "2014-11"
|
||||
},
|
||||
{
|
||||
"title": "신규 액세스 포인트의 위치 인식 방법 및 이를 이용하는 서버",
|
||||
"inventors": "고석주, 김우주, 이민형, 하태윤, 심대섭, 조정근, 강보영, 서대화",
|
||||
"application_no": "2013-0167207",
|
||||
"application_at": "2013-12-30",
|
||||
"registration_no": "1568365",
|
||||
"registration_at": "2015-11-05",
|
||||
"country": "한국",
|
||||
"published_at": "2013-12"
|
||||
},
|
||||
{
|
||||
"title": "디바이스 탐색 장치 및 디바이스 탐색 방법",
|
||||
"inventors": "고석주, 이상헌, 최상일",
|
||||
"application_no": "2013-0140737",
|
||||
"application_at": "2013-11-19",
|
||||
"registration_no": "1405248",
|
||||
"registration_at": "2015-04-17",
|
||||
"country": "한국",
|
||||
"published_at": "2013-11"
|
||||
},
|
||||
{
|
||||
"title": "오픈플로우 통신 시스템 및 방법",
|
||||
"inventors": "고석주, 김지인, 최낙중",
|
||||
"application_no": "2013-0140736",
|
||||
"application_at": "2013-11-19",
|
||||
"registration_no": "1525047",
|
||||
"registration_at": "2015-05-27",
|
||||
"country": "한국",
|
||||
"published_at": "2013-11"
|
||||
},
|
||||
{
|
||||
"title": "이동통신 시스템의 추적 영역 코드 구성 장치 및 방법",
|
||||
"inventors": "고석주, 강형우, 백태산, 강현구",
|
||||
"application_no": "2013-0140734",
|
||||
"application_at": "2013-11-19",
|
||||
"registration_no": "1538453",
|
||||
"registration_at": "2015-07-15",
|
||||
"country": "한국",
|
||||
"published_at": "2013-11"
|
||||
},
|
||||
{
|
||||
"title": "프로세스 모니터링과 키보드 잠금을 이용한 프로세스 관리 방법 및 프로세스 관리 장치",
|
||||
"inventors": "고석주, 박진호, 이재휘",
|
||||
"application_no": "2013-0108587",
|
||||
"application_at": "2013-09-10",
|
||||
"registration_no": "1515493",
|
||||
"registration_at": "2015-04-21",
|
||||
"country": "한국",
|
||||
"published_at": "2013-09"
|
||||
},
|
||||
{
|
||||
"title": "공유기 탐지 장치 및 공유기 탐지 방법",
|
||||
"inventors": "고석주, 박진호, 김철민",
|
||||
"application_no": "2013-0091483",
|
||||
"application_at": "2013-08-01",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2013-08"
|
||||
},
|
||||
{
|
||||
"title": "호스트 식별 프로토콜 네트워크 환경의 통신 시스템 및 방법",
|
||||
"inventors": "고석주, 최상일",
|
||||
"application_no": "2012-0154836",
|
||||
"application_at": "2012-12-27",
|
||||
"registration_no": "1405248",
|
||||
"registration_at": "2014-06-02",
|
||||
"country": "한국",
|
||||
"published_at": "2012-12"
|
||||
},
|
||||
{
|
||||
"title": "액세스 라우터 및 그를 이용한 핸드오버 제어 방법",
|
||||
"inventors": "고석주, 최낙중, 김지인",
|
||||
"application_no": "2012-0154835",
|
||||
"application_at": "2012-12-27",
|
||||
"registration_no": "1447104",
|
||||
"registration_at": "2014-09-26",
|
||||
"country": "한국",
|
||||
"published_at": "2012-12"
|
||||
},
|
||||
{
|
||||
"title": "호스트 식별 프로토콜 네트워크 환경의 이동통신 시스템 및 방법",
|
||||
"inventors": "고석주, 김지인, 이상헌",
|
||||
"application_no": "2012-0146740",
|
||||
"application_at": "2012-12-14",
|
||||
"registration_no": "1459628",
|
||||
"registration_at": "2014-11-03",
|
||||
"country": "한국",
|
||||
"published_at": "2012-12"
|
||||
},
|
||||
{
|
||||
"title": "라우터의 호스트 위치 관리 방법",
|
||||
"inventors": "고석주, 김지인",
|
||||
"application_no": "2012-0030058",
|
||||
"application_at": "2012-03-23",
|
||||
"registration_no": "1356721",
|
||||
"registration_at": "2014-01-20",
|
||||
"country": "한국",
|
||||
"published_at": "2012-03"
|
||||
},
|
||||
{
|
||||
"title": "분산형 구조를 이용한 데이터 통신 방법",
|
||||
"inventors": "고석주, 모닙고하르, 이재경",
|
||||
"application_no": "2011-0111384",
|
||||
"application_at": "2011-10-28",
|
||||
"registration_no": "1311864",
|
||||
"registration_at": "2013-09-17",
|
||||
"country": "한국",
|
||||
"published_at": "2011-10"
|
||||
},
|
||||
{
|
||||
"title": "라우터, 그것을 포함하는 통신 네트워크 시스템 및 그것의 이동성 제어 방법",
|
||||
"inventors": "고석주, 최상일",
|
||||
"application_no": "2011-0107533",
|
||||
"application_at": "2011-10-20",
|
||||
"registration_no": "1329331",
|
||||
"registration_at": "2013-11-07",
|
||||
"country": "한국",
|
||||
"published_at": "2011-10"
|
||||
},
|
||||
{
|
||||
"title": "모바일 액세스 게이트웨이 및 이를 이용한 이동성 제어 방법",
|
||||
"inventors": "고석주, 김지인",
|
||||
"application_no": "2011-0057608",
|
||||
"application_at": "2011-06-14",
|
||||
"registration_no": "1223047",
|
||||
"registration_at": "2013-01-10",
|
||||
"country": "한국",
|
||||
"published_at": "2011-06"
|
||||
},
|
||||
{
|
||||
"title": "멀티홈잉 환경에서 프록시 모바일 인터넷 프로토콜을 사용하는 이동통신 시스템 및 그것의 핸드오버 방법",
|
||||
"inventors": "고석주, 김지인",
|
||||
"application_no": "2011-0001905",
|
||||
"application_at": "2011-01-07",
|
||||
"registration_no": "1189140",
|
||||
"registration_at": "2012-10-02",
|
||||
"country": "한국",
|
||||
"published_at": "2011-01"
|
||||
},
|
||||
{
|
||||
"title": "멀티캐스트 방법 및 억세스 게이트웨이",
|
||||
"inventors": "고석주, 모닙고하르, 이재경",
|
||||
"application_no": "2010-0137897",
|
||||
"application_at": "2010-12-29",
|
||||
"registration_no": "1200407",
|
||||
"registration_at": "2012-11-06",
|
||||
"country": "한국",
|
||||
"published_at": "2010-12"
|
||||
},
|
||||
{
|
||||
"title": "프록시 모바일 인터넷 프로토콜을 사용하는 이동통신 시스템 및 그것의 핸드오버 방법",
|
||||
"inventors": "고석주, 김지인",
|
||||
"application_no": "2010-0108058",
|
||||
"application_at": "2010-11-02",
|
||||
"registration_no": "1258238",
|
||||
"registration_at": "2013-04-19",
|
||||
"country": "한국",
|
||||
"published_at": "2010-11"
|
||||
},
|
||||
{
|
||||
"title": "이동 단말, 통신 네트워크 및 그것의 이동성 제어 방법",
|
||||
"inventors": "고석주, 최상일",
|
||||
"application_no": "2010-0107701",
|
||||
"application_at": "2010-11-01",
|
||||
"registration_no": "1177354",
|
||||
"registration_at": "2012-08-21",
|
||||
"country": "한국",
|
||||
"published_at": "2010-11"
|
||||
},
|
||||
{
|
||||
"title": "빠른 핸드오버를 지원하기 위한 이동통신 시스템 및 방법",
|
||||
"inventors": "고석주, 모닙고하르, 박재완",
|
||||
"application_no": "2010-0039688",
|
||||
"application_at": "2010-04-28",
|
||||
"registration_no": "1091397",
|
||||
"registration_at": "2011-12-01",
|
||||
"country": "한국",
|
||||
"published_at": "2010-04"
|
||||
},
|
||||
{
|
||||
"title": "이동 단말간 데이터 전송 시스템 및 그 방법",
|
||||
"inventors": "고석주, 권순홍",
|
||||
"application_no": "2009-0048797",
|
||||
"application_at": "2009-06-02",
|
||||
"registration_no": "1078156",
|
||||
"registration_at": "2011-10-24",
|
||||
"country": "한국",
|
||||
"published_at": "2009-06"
|
||||
},
|
||||
{
|
||||
"title": "프록시 모바일 IPv6망내 이동단말간 통신 경로 최적화 시스템 및 그 방법",
|
||||
"inventors": "고석주, 김지인",
|
||||
"application_no": "2009-0048796",
|
||||
"application_at": "2009-06-02",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2009-06"
|
||||
},
|
||||
{
|
||||
"title": "이종 무선망간 수직 핸드오버를 위한 이동 SCTP의 적응적 혼잡 제어 방법 및 장치",
|
||||
"inventors": "고석주, 김동필",
|
||||
"application_no": "2009-0013072",
|
||||
"application_at": "2009-02-17",
|
||||
"registration_no": "1048251",
|
||||
"registration_at": "2011-07-04",
|
||||
"country": "한국",
|
||||
"published_at": "2009-02"
|
||||
},
|
||||
{
|
||||
"title": "서비스 품질 향상을 위한 PR-SCTP 기반 실시간 멀티미디어 데이터 전송 방법",
|
||||
"inventors": "고석주, 김상태",
|
||||
"application_no": "2008-0135342",
|
||||
"application_at": "2008-12-29",
|
||||
"registration_no": "1040780",
|
||||
"registration_at": "2011-06-03",
|
||||
"country": "한국",
|
||||
"published_at": "2008-12"
|
||||
},
|
||||
{
|
||||
"title": "이종 무선망간의 수직적 핸드오버를 위한 데이터 전송 방법 및 장치",
|
||||
"inventors": "고석주, 김동필",
|
||||
"application_no": "2008-0083995",
|
||||
"application_at": "2008-08-27",
|
||||
"registration_no": "0980592",
|
||||
"registration_at": "2010-08-31",
|
||||
"country": "한국",
|
||||
"published_at": "2008-08"
|
||||
},
|
||||
{
|
||||
"title": "무선통신용 멀티캐스트 핸드오버 방법",
|
||||
"inventors": "고석주, 박재성",
|
||||
"application_no": "2008-0077918",
|
||||
"application_at": "2008-08-08",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2008-08"
|
||||
},
|
||||
{
|
||||
"title": "무선 인터넷 망에서의 바이캐스팅을 이용한 SIP 핸드오버 방법 및 이동 단말",
|
||||
"inventors": "고석주, 이동화",
|
||||
"application_no": "2008-0052456",
|
||||
"application_at": "2008-06-04",
|
||||
"registration_no": "0987555",
|
||||
"registration_at": "2010-10-06",
|
||||
"country": "한국",
|
||||
"published_at": "2008-06"
|
||||
},
|
||||
{
|
||||
"title": "무선 인터넷 환경에서의 바이캐스팅 기반 SCTP 핸드오버 방법 및 이동 단말",
|
||||
"inventors": "고석주, 권순홍, 김동필",
|
||||
"application_no": "2008-0049203",
|
||||
"application_at": "2008-05-27",
|
||||
"registration_no": "0980582",
|
||||
"registration_at": "2010-08-31",
|
||||
"country": "한국",
|
||||
"published_at": "2008-05"
|
||||
},
|
||||
{
|
||||
"title": "Proxy MIP 기반 무선 인터넷 망에서의 바이캐스팅을 이용한 핸드오버 방법 및 장치",
|
||||
"inventors": "고석주, 김지인",
|
||||
"application_no": "2008-0049153",
|
||||
"application_at": "2008-05-27",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2008-05"
|
||||
},
|
||||
{
|
||||
"title": "윈도우 기반의 이동 단말에서 SCTPLIB를 이용한 mSCTP 핸드오버 방법",
|
||||
"inventors": "김용진, 고석주, 이동화, 김상태",
|
||||
"application_no": "2007-0139730",
|
||||
"application_at": "2007-12-28",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2007-12"
|
||||
},
|
||||
{
|
||||
"title": "전송 처리율 향상을 위한 SCTP 우선경로 설정 방법",
|
||||
"inventors": "고석주, 주수경",
|
||||
"application_no": "2007-0102031",
|
||||
"application_at": "2007-10-10",
|
||||
"registration_no": null,
|
||||
"registration_at": null,
|
||||
"country": "한국",
|
||||
"published_at": "2007-10"
|
||||
},
|
||||
{
|
||||
"title": "무선 네트워크에서 패킷의 손상정보를 사용하는 TCP 혼잡제어 방법",
|
||||
"inventors": "고석주, 최린, 이동화, 김용진",
|
||||
"application_no": "2007-0061209",
|
||||
"application_at": "2007-06-21",
|
||||
"registration_no": "0870619",
|
||||
"registration_at": "2008-11-19",
|
||||
"country": "한국",
|
||||
"published_at": "2007-06"
|
||||
},
|
||||
{
|
||||
"title": "이동 단말의 SCTP 핸드오버와 모바일 아이피의 연동 장치 및 그 방법",
|
||||
"inventors": "고석주, 김동필",
|
||||
"application_no": "2007-0050648",
|
||||
"application_at": "2007-05-25",
|
||||
"registration_no": "0880112",
|
||||
"registration_at": "2009-01-15",
|
||||
"country": "한국",
|
||||
"published_at": "2007-05"
|
||||
},
|
||||
{
|
||||
"title": "청크첵섬을 사용하는 무선 인터넷 에스씨티피 송수신 시스템 및 방법",
|
||||
"inventors": "김용진, 정종일, 고석주",
|
||||
"application_no": "2006-0117121",
|
||||
"application_at": "2006-11-24",
|
||||
"registration_no": "0780921",
|
||||
"registration_at": "2007-11-23",
|
||||
"country": "한국",
|
||||
"published_at": "2006-11"
|
||||
},
|
||||
{
|
||||
"title": "데이터 링크계층의 링크 정보를 이용하여 핸드오버를 수행하는 단말장치",
|
||||
"inventors": "고석주, 김동필",
|
||||
"application_no": "2005-0110213",
|
||||
"application_at": "2005-11-17",
|
||||
"registration_no": "0685740",
|
||||
"registration_at": "2007-02-15",
|
||||
"country": "한국",
|
||||
"published_at": "2005-11"
|
||||
},
|
||||
{
|
||||
"title": "SCTP 기반의 핸드오버 기능을 구비한 단말장치 및 핸드오버 방법",
|
||||
"inventors": "고석주, 김동필",
|
||||
"application_no": "US 11-915457 (미국, 2007.11.26)",
|
||||
"application_at": "2005-05-27",
|
||||
"registration_no": "US 8,644,248 (미국, 2014.2.4)",
|
||||
"registration_at": "2007-01-26",
|
||||
"country": "한국",
|
||||
"published_at": "2005-05"
|
||||
}
|
||||
]
|
||||
+1157
-1422
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"name_en": "Quick UDP Internet Connection (QUIC)",
|
||||
"name_kr": null,
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"name_en": "QUIC-based Mobility and Multi-Streaming Support",
|
||||
"name_kr": null,
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"name_en": "Services and Protocols for Internet-of-Things (IoT)",
|
||||
"name_kr": null,
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"name_en": "Constrained Application Protocol (CoAP)",
|
||||
"name_kr": null,
|
||||
"display_order": 4
|
||||
},
|
||||
{
|
||||
"name_en": "In-Vehicle Infotainment (IVI)",
|
||||
"name_kr": null,
|
||||
"display_order": 5
|
||||
},
|
||||
{
|
||||
"name_en": "Optical Wireless or Visible Light Communications (OWC/VLC)",
|
||||
"name_kr": null,
|
||||
"display_order": 6
|
||||
},
|
||||
{
|
||||
"name_en": "Lighting Control Networks (PLASA E1.45) for LED-based VLC",
|
||||
"name_kr": null,
|
||||
"display_order": 7
|
||||
},
|
||||
{
|
||||
"name_en": "Mobility Management: Distributed Mobility Control",
|
||||
"name_kr": null,
|
||||
"display_order": 8
|
||||
},
|
||||
{
|
||||
"name_en": "Multicast Routing Protocols and Reliable Multicasting",
|
||||
"name_kr": null,
|
||||
"display_order": 9
|
||||
},
|
||||
{
|
||||
"name_en": "mobile SCTP (mSCTP): Soft Handover in Transport Layer",
|
||||
"name_kr": null,
|
||||
"display_order": 10
|
||||
},
|
||||
{
|
||||
"name_en": "Stream Control Transmission Protocol (SCTP)",
|
||||
"name_kr": null,
|
||||
"display_order": 11
|
||||
},
|
||||
{
|
||||
"name_en": "Telecommunication Optimization: Network Planning and Management",
|
||||
"name_kr": null,
|
||||
"display_order": 12
|
||||
},
|
||||
{
|
||||
"name_en": "TAC/TAL Configuration for Paging and Location Management",
|
||||
"name_kr": null,
|
||||
"display_order": 13
|
||||
},
|
||||
{
|
||||
"name_en": "Tabu Search and Genetic Algorithm",
|
||||
"name_kr": null,
|
||||
"display_order": 14
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"slug": "iot-standards",
|
||||
"title": "AIoT & AI-RAN",
|
||||
"abstract": "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.",
|
||||
"keywords": [
|
||||
"AIoT",
|
||||
"agent-based IoT",
|
||||
"AI-RAN",
|
||||
"constrained edge protocols",
|
||||
"distributed autonomous control"
|
||||
],
|
||||
"standardsTrack": "National Research Foundation of Korea (NRF)",
|
||||
"standardsOrg": "NRF",
|
||||
"period": "2026 ~ Present",
|
||||
"deliverables": []
|
||||
},
|
||||
{
|
||||
"slug": "ccis",
|
||||
"title": "MCM",
|
||||
"abstract": "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.",
|
||||
"keywords": [
|
||||
"MCM",
|
||||
"metaverse",
|
||||
"shared virtual space",
|
||||
"coordinated participation",
|
||||
"service classification"
|
||||
],
|
||||
"standardsTrack": "IEC/TC 100/WG 12 supported by KEIT",
|
||||
"standardsOrg": "IEC TC100",
|
||||
"period": "2026 ~ Present",
|
||||
"deliverables": []
|
||||
},
|
||||
{
|
||||
"slug": "vlc-iot",
|
||||
"title": "MVQM",
|
||||
"abstract": "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.",
|
||||
"keywords": [
|
||||
"MVQM",
|
||||
"visual quality",
|
||||
"hybrid visual assets",
|
||||
"level of detail",
|
||||
"area of interest"
|
||||
],
|
||||
"standardsTrack": "IEC/TC 100/WG 12 supported by KEIT",
|
||||
"standardsOrg": "IEC TC100",
|
||||
"period": "2026 ~ Present",
|
||||
"deliverables": []
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,4 @@
|
||||
// AUTO-GENERATED by scripts/extract/generate_all.py — DO NOT EDIT BY HAND
|
||||
import { StandardsBody } from "./types";
|
||||
|
||||
export const standardsBodies: StandardsBody[] = [
|
||||
[
|
||||
{
|
||||
"org": "IEC TC100",
|
||||
"full": "Multimedia Systems and Equipment",
|
||||
@@ -384,4 +381,4 @@ export const standardsBodies: StandardsBody[] = [
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
anl-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-}
|
||||
container_name: anl-app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8080
|
||||
- DB_PATH=/app/data/anl.db
|
||||
- GIN_MODE=release
|
||||
- CORS_ALLOW_ORIGINS=*
|
||||
- AUTO_MIGRATE=true
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set -- see .env.example}
|
||||
volumes:
|
||||
- ./volumes/data:/app/data
|
||||
# - anl-db-data:/app/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:8080/api/v1/health" ]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# volumes:
|
||||
# anl-db-data:
|
||||
# name: anl-sqlite-storage
|
||||
@@ -0,0 +1,6 @@
|
||||
# ==============================================================================
|
||||
# AI Agent Networking Lab (ANL) Next.js Frontend Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Backend REST API Base URL (used at build time / SSR)
|
||||
API_BASE_URL=http://localhost:8080/api/v1
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function DirectionsBlock() {
|
||||
return (
|
||||
<section className="mt-20 pt-16 border-t border-line" id="directions">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
Location & Contact
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Left info column */}
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<h2 className="font-serif text-3xl font-normal text-ink">
|
||||
오시는 길 & 연구실 소재지
|
||||
</h2>
|
||||
<p className="text-ink-mute text-base leading-relaxed">
|
||||
경북대학교 AI 에이전트 네트워크 연구실 (ANL)은 IT대학 5호관에 위치하고 있으며, 차세대 차세대 에이전트 오케스트레이션 및 고성능 네트워크 백본 연구를 수행합니다.
|
||||
</p>
|
||||
|
||||
<address className="not-italic space-y-4 pt-2 text-sm font-sans text-ink">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-ink-mute w-20 pt-0.5">
|
||||
소재지
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
경북대학교 IT대학 5호관(415동) 527호
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute mt-0.5">
|
||||
School of Computer Science and Engineering, Kyungpook National University
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-ink-mute w-20">
|
||||
지도교수
|
||||
</span>
|
||||
<span>
|
||||
고석주 교수 (<a href="mailto:sjkoh@knu.ac.kr" className="text-vermillion hover:underline">sjkoh@knu.ac.kr</a>)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-ink-mute w-20 pt-0.5">
|
||||
관련 기관
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs font-mono text-cobalt">
|
||||
<a href="http://www.knu.ac.kr/" target="_blank" rel="noreferrer" className="hover:underline">
|
||||
경북대학교 (KNU) ↗
|
||||
</a>
|
||||
<a href="http://cse.knu.ac.kr/" target="_blank" rel="noreferrer" className="hover:underline">
|
||||
컴퓨터학부 ↗
|
||||
</a>
|
||||
<a href="http://it.knu.ac.kr/" target="_blank" rel="noreferrer" className="hover:underline">
|
||||
IT대학 ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
{/* Right card column */}
|
||||
<div className="lg:col-span-5 bg-paper p-6 rounded-lg border border-line flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider">
|
||||
Research Environment
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-normal text-ink">
|
||||
AI Agent Networking Lab
|
||||
</h3>
|
||||
<p className="text-xs text-ink-mute leading-relaxed">
|
||||
QUIC/gRPC 스트리밍 통신 인프라와 컨테이너 격리 프로세스 격리 환경을 포함한 멀티 에이전트 실험실 공간을 운용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 pt-4 border-t border-line flex justify-between items-center text-xs font-mono text-ink-mute">
|
||||
<span>IT-5 Building #527</span>
|
||||
<span className="text-vermillion font-bold">ANL Laboratory</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,37 @@
|
||||
import React from "react";
|
||||
import { researchProjects } from "../../content/projects";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import type { ResearchProject } from "../../content/types";
|
||||
import ProjectPageViewControls from "./ProjectPageViewControls";
|
||||
|
||||
export function ProjectPageView() {
|
||||
const slugs = researchProjects.map((p) => p.slug);
|
||||
interface ProjectPageViewProps {
|
||||
projects?: ResearchProject[];
|
||||
}
|
||||
|
||||
export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps = {}) {
|
||||
const projects = projectsProp ?? [];
|
||||
const slugs = projects.map((p) => p.slug);
|
||||
const trackId = "research-projects-track";
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<Reveal variant="up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Key Initiatives
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
주요 연구 프로젝트 (Research Projects)
|
||||
Research Projects
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
총 {researchProjects.length}개 핵심 국제/국내 표준화 연구 과제
|
||||
{projects.length} core international standardization research projects
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
|
||||
<div
|
||||
@@ -32,16 +40,19 @@ export function ProjectPageView() {
|
||||
role="group"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="연구 프로젝트 목록"
|
||||
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-vermillion/50 rounded"
|
||||
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-cobalt/50 rounded"
|
||||
>
|
||||
{researchProjects.map((proj, idx) => (
|
||||
<article
|
||||
{projects.map((proj, idx) => (
|
||||
<Reveal
|
||||
key={proj.slug}
|
||||
as="article"
|
||||
id={`project-${proj.slug}`}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${researchProjects.length}개 중 ${idx + 1}번째 프로젝트`}
|
||||
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-vermillion/60 transition-colors space-y-5 break-words [word-break:keep-all]"
|
||||
aria-label={`${projects.length}개 중 ${idx + 1}번째 프로젝트`}
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-cobalt/60 transition-colors space-y-5 break-words [word-break:keep-all] h-full"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Header Info */}
|
||||
@@ -50,7 +61,7 @@ export function ProjectPageView() {
|
||||
{proj.title}
|
||||
</h3>
|
||||
{proj.period && (
|
||||
<span className="font-mono text-xs text-vermillion bg-ivory border border-line px-2 py-0.5 rounded shrink-0">
|
||||
<span className="font-mono text-xs text-cobalt-dark bg-ivory border border-line px-2 py-0.5 rounded shrink-0">
|
||||
{proj.period}
|
||||
</span>
|
||||
)}
|
||||
@@ -76,7 +87,7 @@ export function ProjectPageView() {
|
||||
|
||||
{/* Metadata Footer: Standards Track & Deliverables */}
|
||||
<div className="space-y-3 pt-3 border-t border-line/60 text-xs font-mono">
|
||||
{/* Standards Track (or Funder if present, never placeholder if undefined) */}
|
||||
{/* Standards Track / Organization */}
|
||||
{proj.funder ? (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-ink-mute">Funder:</span>
|
||||
@@ -84,13 +95,13 @@ export function ProjectPageView() {
|
||||
</div>
|
||||
) : proj.standardsTrack ? (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-ink-mute">Standards Track:</span>
|
||||
<span className="text-cobalt font-bold">{proj.standardsTrack}</span>
|
||||
<span className="text-ink-mute">Organization:</span>
|
||||
<span className="text-cobalt-dark font-bold">{proj.standardsTrack}</span>
|
||||
</div>
|
||||
) : proj.standardsOrg ? (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-ink-mute">Organization:</span>
|
||||
<span className="text-cobalt font-bold">{proj.standardsOrg}</span>
|
||||
<span className="text-cobalt-dark font-bold">{proj.standardsOrg}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -119,7 +130,7 @@ export function ProjectPageView() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function getClientToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const match = document.cookie.match(new RegExp("(^| )admin_token=([^;]+)"));
|
||||
if (match) return decodeURIComponent(match[2]);
|
||||
return localStorage.getItem("admin_token") || "";
|
||||
}
|
||||
|
||||
export function setClientToken(token: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
document.cookie = `admin_token=${encodeURIComponent(token)}; path=/; max-age=604800; SameSite=Lax`;
|
||||
localStorage.setItem("admin_token", token);
|
||||
}
|
||||
|
||||
export function removeClientToken() {
|
||||
if (typeof window === "undefined") return;
|
||||
document.cookie = "admin_token=; path=/; max-age=0; SameSite=Lax";
|
||||
localStorage.removeItem("admin_token");
|
||||
}
|
||||
|
||||
export function AdminHeader({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-6 mb-6 border-b border-line gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-ink tracking-tight">{title}</h1>
|
||||
{description && <p className="text-sm text-ink/70 mt-1">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="flex items-center gap-3 shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertBanner({ type, message, onClose }: { type: "error" | "success" | "info"; message: string; onClose?: () => void }) {
|
||||
const styles = {
|
||||
error: "bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300",
|
||||
success: "bg-emerald-50 dark:bg-emerald-950/40 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300",
|
||||
info: "bg-blue-50 dark:bg-blue-950/40 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-md border mb-4 flex items-center justify-between text-sm ${styles[type]}`}>
|
||||
<div>{message}</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="text-xs font-semibold underline ml-4 hover:opacity-75">
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, title, onClose, children }: { isOpen: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-paper border border-line rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
|
||||
<h3 className="text-lg font-bold text-ink">{title}</h3>
|
||||
<button onClick={onClose} className="text-ink/60 hover:text-ink text-xl font-bold">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDeleteModal({
|
||||
isOpen,
|
||||
title = "Confirm Deletion",
|
||||
description = "Are you sure you want to delete this item? This action cannot be undone.",
|
||||
onClose,
|
||||
onConfirm,
|
||||
isSubmitting,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isSubmitting?: boolean;
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={title} onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-ink/80">{description}</p>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border border-line bg-paper hover:bg-ivory text-ink font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-red-600 hover:bg-red-700 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Deleting..." : "Delete Permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setClientToken } from "./AdminComponents";
|
||||
import { adminVerifyToken } from "../../../lib/adminApi";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
|
||||
export function AdminLoginForm({ onLoginSuccess }: { onLoginSuccess?: () => void }) {
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token.trim()) {
|
||||
setError("Please enter the admin token.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isValid = await adminVerifyToken(token.trim());
|
||||
if (isValid) {
|
||||
setClientToken(token.trim());
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
router.push("/console");
|
||||
}
|
||||
} else {
|
||||
setError("Invalid admin bearer token. Please check your credentials.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to verify token with backend API.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md bg-paper border border-line rounded-lg shadow-lg p-8 relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-ink">ANL Admin Console</h1>
|
||||
<p className="text-sm text-ink/70 mt-1">Enter your admin bearer token to manage laboratory datasets.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 mb-4 text-sm rounded bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-ink mb-1">
|
||||
Admin Bearer Token
|
||||
</label>
|
||||
<input
|
||||
id="token"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="e.g. admin123"
|
||||
className="w-full px-3 py-2 border border-line rounded-md bg-ivory text-ink focus:outline-none focus:ring-2 focus:ring-cobalt/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Verifying..." : "Sign In to Console"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
|
||||
import { AdminLoginForm } from "./_components/AdminLoginForm";
|
||||
import { adminVerifyToken } from "../../lib/adminApi";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function checkAuth() {
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
if (isMounted) setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await adminVerifyToken(token);
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!isValid) {
|
||||
removeClientToken();
|
||||
setIsAuthenticated(false);
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
if (pathname === "/console/login") {
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
|
||||
<AdminLoginForm
|
||||
onLoginSuccess={() => {
|
||||
setIsAuthenticated(true);
|
||||
router.push("/console");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
const token = typeof window !== "undefined" ? getClientToken() : null;
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
|
||||
<AdminLoginForm onLoginSuccess={() => setIsAuthenticated(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex flex-col items-center justify-center space-y-4">
|
||||
<div className="text-sm text-ink/70">Checking authentication...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated === false) {
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">
|
||||
<AdminLoginForm onLoginSuccess={() => setIsAuthenticated(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: "/console", label: "Dashboard Overview" },
|
||||
{ href: "/console/research-projects", label: "Research Projects" },
|
||||
{ href: "/console/research-areas", label: "Research Areas" },
|
||||
{ href: "/console/members", label: "Members & Alumni" },
|
||||
{ href: "/console/publications", label: "Publications & Patents" },
|
||||
{ href: "/console/lectures", label: "Lectures & Courses" },
|
||||
{ href: "/console/standardization", label: "Standardization" },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
removeClientToken();
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory text-ink flex flex-col md:flex-row">
|
||||
{/* Fixed Sidebar */}
|
||||
<aside className="w-full md:w-64 md:fixed md:top-0 md:left-0 md:bottom-0 md:h-screen bg-paper border-r border-line flex flex-col shrink-0 z-30 overflow-y-auto">
|
||||
<div className="p-6 border-b border-line shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
|
||||
<p className="text-xs text-ink/60 mt-0.5">Lab Data Management</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<ThemeToggle />
|
||||
<span className="text-[10px] bg-cobalt/10 text-cobalt font-medium px-1.5 py-0.5 rounded leading-none">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-1.5 flex-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const cleanPath = (pathname || "").replace(/\/+$/, "") || "/";
|
||||
const cleanHref = item.href.replace(/\/+$/, "");
|
||||
const isActive =
|
||||
cleanHref === "/console"
|
||||
? cleanPath === "/console"
|
||||
: cleanPath === cleanHref || cleanPath.startsWith(`${cleanHref}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block px-3 py-2 text-sm font-medium rounded-md transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-cobalt text-white shadow-sm font-semibold"
|
||||
: "text-ink/80 hover:bg-cobalt/10 hover:text-cobalt dark:hover:bg-cobalt/20 dark:hover:text-cobalt-light"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-line space-y-2 shrink-0 bg-paper">
|
||||
<Link
|
||||
href="/"
|
||||
className="block w-full text-center px-3 py-2 text-xs font-medium rounded border border-line hover:border-cobalt/40 hover:bg-cobalt/10 hover:text-cobalt text-ink/80 transition-colors"
|
||||
>
|
||||
← Return to Public Site
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-center px-3 py-2 text-xs font-medium rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area with offset for fixed sidebar */}
|
||||
<main className="flex-1 md:ml-64 p-6 md:p-10 min-h-screen overflow-x-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
||||
import {
|
||||
adminGetSemesters,
|
||||
adminCreateSemester,
|
||||
adminUpdateSemester,
|
||||
adminDeleteSemester,
|
||||
adminCreateCourse,
|
||||
adminUpdateCourse,
|
||||
adminDeleteCourse,
|
||||
AdminSemester,
|
||||
AdminCourse,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function AdminLecturesPage() {
|
||||
const [semesters, setSemesters] = useState<AdminSemester[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Semester Modal State
|
||||
const [isSemModalOpen, setIsSemModalOpen] = useState(false);
|
||||
const [editingSemester, setEditingSemester] = useState<AdminSemester | null>(null);
|
||||
const [semForm, setSemForm] = useState({
|
||||
year: new Date().getFullYear(),
|
||||
term: "Spring" as "Spring" | "Fall",
|
||||
});
|
||||
|
||||
// Course Modal State
|
||||
const [isCourseModalOpen, setIsCourseModalOpen] = useState(false);
|
||||
const [targetSemesterId, setTargetSemesterId] = useState<number | null>(null);
|
||||
const [editingCourse, setEditingCourse] = useState<AdminCourse | null>(null);
|
||||
const [courseForm, setCourseForm] = useState({
|
||||
code: "",
|
||||
name_en: "",
|
||||
name_kr: "",
|
||||
note: "",
|
||||
display_order: 0,
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingSemId, setDeletingSemId] = useState<number | null>(null);
|
||||
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
|
||||
|
||||
const loadSemesters = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await adminGetSemesters();
|
||||
setSemesters(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load semesters.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSemesters();
|
||||
}, []);
|
||||
|
||||
// Semester Actions
|
||||
const handleOpenSemCreate = () => {
|
||||
setEditingSemester(null);
|
||||
setSemForm({
|
||||
year: new Date().getFullYear(),
|
||||
term: "Spring",
|
||||
});
|
||||
setIsSemModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenSemEdit = (s: AdminSemester) => {
|
||||
setEditingSemester(s);
|
||||
setSemForm({
|
||||
year: s.year,
|
||||
term: s.term,
|
||||
});
|
||||
setIsSemModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSemSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
const payload = {
|
||||
year: Number(semForm.year),
|
||||
term: semForm.term,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingSemester) {
|
||||
await adminUpdateSemester(token, editingSemester.id, payload);
|
||||
setSuccessMsg(`Semester ${payload.year} ${payload.term} updated.`);
|
||||
} else {
|
||||
await adminCreateSemester(token, payload);
|
||||
setSuccessMsg(`Semester ${payload.year} ${payload.term} created.`);
|
||||
}
|
||||
setIsSemModalOpen(false);
|
||||
await loadSemesters();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save semester.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteSem = async () => {
|
||||
if (!deletingSemId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteSemester(token, deletingSemId);
|
||||
setSuccessMsg("Semester and all its courses deleted successfully (Cascade).");
|
||||
setDeletingSemId(null);
|
||||
await loadSemesters();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete semester.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Course Actions
|
||||
const handleOpenCourseCreate = (semesterId: number) => {
|
||||
setTargetSemesterId(semesterId);
|
||||
setEditingCourse(null);
|
||||
const sem = semesters.find((s) => s.id === semesterId);
|
||||
setCourseForm({
|
||||
code: "",
|
||||
name_en: "",
|
||||
name_kr: "",
|
||||
note: "",
|
||||
display_order: (sem?.courses?.length || 0) + 1,
|
||||
});
|
||||
setIsCourseModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenCourseEdit = (c: AdminCourse) => {
|
||||
setTargetSemesterId(c.semester_id);
|
||||
setEditingCourse(c);
|
||||
setCourseForm({
|
||||
code: c.code,
|
||||
name_en: c.name_en,
|
||||
name_kr: c.name_kr || "",
|
||||
note: c.note || "",
|
||||
display_order: c.display_order,
|
||||
});
|
||||
setIsCourseModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCourseSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token || !targetSemesterId) return;
|
||||
|
||||
const payload = {
|
||||
code: courseForm.code.trim(),
|
||||
name_en: courseForm.name_en.trim(),
|
||||
name_kr: courseForm.name_kr.trim() || undefined,
|
||||
note: courseForm.note.trim() || undefined,
|
||||
display_order: Number(courseForm.display_order) || 0,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingCourse) {
|
||||
await adminUpdateCourse(token, editingCourse.id, payload);
|
||||
setSuccessMsg(`Course "${payload.name_en}" updated.`);
|
||||
} else {
|
||||
await adminCreateCourse(token, targetSemesterId, payload);
|
||||
setSuccessMsg(`Course "${payload.name_en}" added.`);
|
||||
}
|
||||
setIsCourseModalOpen(false);
|
||||
await loadSemesters();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save course.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteCourse = async () => {
|
||||
if (!deletingCourseId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteCourse(token, deletingCourseId);
|
||||
setSuccessMsg("Course deleted successfully.");
|
||||
setDeletingCourseId(null);
|
||||
await loadSemesters();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete course.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Lectures & Courses Management"
|
||||
description="Manage lecture history by academic semesters and undergraduate/graduate courses."
|
||||
action={
|
||||
<button
|
||||
onClick={handleOpenSemCreate}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
||||
>
|
||||
+ Add New Semester
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading semesters and courses...</div>
|
||||
) : semesters.length === 0 ? (
|
||||
<div className="bg-paper border border-line rounded-lg p-12 text-center text-ink/60">
|
||||
No lecture semesters recorded. Click "+ Add New Semester" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{semesters.map((sem) => (
|
||||
<div key={sem.id} className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
{/* Semester Header Bar */}
|
||||
<div className="px-6 py-4 bg-ivory border-b border-line flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-bold text-base text-ink">
|
||||
{sem.year} {sem.term} Semester
|
||||
</span>
|
||||
<span className="text-xs bg-cobalt/10 text-cobalt px-2 py-0.5 rounded font-medium">
|
||||
{sem.courses?.length || 0} Courses
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenCourseCreate(sem.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-cobalt text-white font-medium hover:bg-cobalt/90"
|
||||
>
|
||||
+ Add Course
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenSemEdit(sem)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingSemId(sem.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete Semester
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Courses Table */}
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs font-semibold text-ink/60 bg-paper">
|
||||
<th className="p-3 w-16">Order</th>
|
||||
<th className="p-3 w-32">Course Code</th>
|
||||
<th className="p-3">Course Name (En / Kr)</th>
|
||||
<th className="p-3">Note</th>
|
||||
<th className="p-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{!sem.courses || sem.courses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-xs text-ink/50">
|
||||
No courses added for this semester.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sem.courses.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-ivory/40 text-xs">
|
||||
<td className="p-3 font-mono text-ink/60">{c.display_order}</td>
|
||||
<td className="p-3 font-mono font-medium text-cobalt">{c.code}</td>
|
||||
<td className="p-3 font-medium text-ink">
|
||||
{c.name_en} {c.name_kr && <span className="text-ink/60 font-normal">({c.name_kr})</span>}
|
||||
</td>
|
||||
<td className="p-3 text-ink/60">{c.note || "—"}</td>
|
||||
<td className="p-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenCourseEdit(c)}
|
||||
className="px-2 py-0.5 rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingCourseId(c.id)}
|
||||
className="px-2 py-0.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Semester Modal */}
|
||||
<Modal
|
||||
isOpen={isSemModalOpen}
|
||||
title={editingSemester ? "Edit Semester" : "Create Semester"}
|
||||
onClose={() => setIsSemModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleSemSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Academic Year *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={semForm.year}
|
||||
onChange={(e) => setSemForm({ ...semForm, year: Number(e.target.value) })}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Term *</label>
|
||||
<select
|
||||
value={semForm.term}
|
||||
onChange={(e) => setSemForm({ ...semForm, term: e.target.value as "Spring" | "Fall" })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="Spring">Spring</option>
|
||||
<option value="Fall">Fall</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSemModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingSemester ? "Save Changes" : "Create Semester"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Course Modal */}
|
||||
<Modal
|
||||
isOpen={isCourseModalOpen}
|
||||
title={editingCourse ? "Edit Course" : "Add New Course"}
|
||||
onClose={() => setIsCourseModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleCourseSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Course Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={courseForm.code}
|
||||
onChange={(e) => setCourseForm({ ...courseForm, code: e.target.value })}
|
||||
placeholder="e.g. ITNT683"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={courseForm.display_order}
|
||||
onChange={(e) => setCourseForm({ ...courseForm, display_order: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Course Name (English) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={courseForm.name_en}
|
||||
onChange={(e) => setCourseForm({ ...courseForm, name_en: e.target.value })}
|
||||
placeholder="e.g. Digital Signal Processing"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Course Name (Korean optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={courseForm.name_kr}
|
||||
onChange={(e) => setCourseForm({ ...courseForm, name_kr: e.target.value })}
|
||||
placeholder="e.g. 디지털신호처리"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Note (e.g. Graduate/Undergraduate)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={courseForm.note}
|
||||
onChange={(e) => setCourseForm({ ...courseForm, note: e.target.value })}
|
||||
placeholder="e.g. Graduate"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCourseModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingCourse ? "Save Changes" : "Add Course"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingSemId !== null}
|
||||
onClose={() => setDeletingSemId(null)}
|
||||
onConfirm={handleConfirmDeleteSem}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Semester (Cascade Warning)"
|
||||
description="Are you sure you want to delete this semester? All courses under this semester will also be permanently deleted (Database Cascade)."
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingCourseId !== null}
|
||||
onClose={() => setDeletingCourseId(null)}
|
||||
onConfirm={handleConfirmDeleteCourse}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Course"
|
||||
description="Are you sure you want to delete this course?"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AdminLoginForm } from "../_components/AdminLoginForm";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
return <AdminLoginForm />;
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
||||
import {
|
||||
adminGetMembers,
|
||||
adminCreateMember,
|
||||
adminUpdateMember,
|
||||
adminDeleteMember,
|
||||
adminGetAlumni,
|
||||
adminCreateAlumnus,
|
||||
adminUpdateAlumnus,
|
||||
adminDeleteAlumnus,
|
||||
AdminMember,
|
||||
AdminAlumnus,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
const MEMBER_DEGREES = [
|
||||
"Professor",
|
||||
"PhD",
|
||||
"PhDCandidate",
|
||||
"PhDStudent",
|
||||
"MSCandidate",
|
||||
"MSStudent",
|
||||
];
|
||||
|
||||
const ALUMNI_DEGREES = ["PhD", "MS"];
|
||||
|
||||
export default function AdminMembersPage() {
|
||||
const [tab, setTab] = useState<"active" | "alumni">("active");
|
||||
const [members, setMembers] = useState<AdminMember[]>([]);
|
||||
const [alumni, setAlumni] = useState<AdminAlumnus[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Active Member Modal State
|
||||
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false);
|
||||
const [editingMember, setEditingMember] = useState<AdminMember | null>(null);
|
||||
const [memberForm, setMemberForm] = useState({
|
||||
name_ko: "",
|
||||
name_en: "",
|
||||
degree: "PhDStudent",
|
||||
affiliation: "",
|
||||
email: "",
|
||||
is_advisor: false,
|
||||
display_order: 0,
|
||||
});
|
||||
|
||||
// Alumni Modal State
|
||||
const [isAlumniModalOpen, setIsAlumniModalOpen] = useState(false);
|
||||
const [editingAlumnus, setEditingAlumnus] = useState<AdminAlumnus | null>(null);
|
||||
const [alumniForm, setAlumniForm] = useState({
|
||||
name_ko: "",
|
||||
name_en: "",
|
||||
degree: "MS",
|
||||
graduated_at: "",
|
||||
major: "",
|
||||
current_position: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingMemberId, setDeletingMemberId] = useState<number | null>(null);
|
||||
const [deletingAlumniId, setDeletingAlumniId] = useState<number | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const [mList, aList] = await Promise.all([adminGetMembers(), adminGetAlumni()]);
|
||||
setMembers(mList);
|
||||
setAlumni(aList);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load members data.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Member Handlers
|
||||
const handleOpenMemberCreate = () => {
|
||||
setEditingMember(null);
|
||||
setMemberForm({
|
||||
name_ko: "",
|
||||
name_en: "",
|
||||
degree: "PhDStudent",
|
||||
affiliation: "",
|
||||
email: "",
|
||||
is_advisor: false,
|
||||
display_order: members.length + 1,
|
||||
});
|
||||
setIsMemberModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenMemberEdit = (m: AdminMember) => {
|
||||
setEditingMember(m);
|
||||
setMemberForm({
|
||||
name_ko: m.name_ko,
|
||||
name_en: m.name_en,
|
||||
degree: m.degree,
|
||||
affiliation: m.affiliation || "",
|
||||
email: m.email || "",
|
||||
is_advisor: m.is_advisor,
|
||||
display_order: m.display_order,
|
||||
});
|
||||
setIsMemberModalOpen(true);
|
||||
};
|
||||
|
||||
const handleMemberSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
setError("Unauthorized. Please log in.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name_ko: memberForm.name_ko.trim(),
|
||||
name_en: memberForm.name_en.trim(),
|
||||
degree: memberForm.degree,
|
||||
affiliation: memberForm.affiliation.trim() || undefined,
|
||||
email: memberForm.email.trim() || undefined,
|
||||
is_advisor: memberForm.is_advisor,
|
||||
display_order: Number(memberForm.display_order) || 0,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingMember) {
|
||||
await adminUpdateMember(token, editingMember.id, payload);
|
||||
setSuccessMsg(`Member "${payload.name_ko}" updated successfully.`);
|
||||
} else {
|
||||
await adminCreateMember(token, payload);
|
||||
setSuccessMsg(`Member "${payload.name_ko}" created successfully.`);
|
||||
}
|
||||
setIsMemberModalOpen(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save member.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteMember = async () => {
|
||||
if (!deletingMemberId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteMember(token, deletingMemberId);
|
||||
setSuccessMsg("Member deleted successfully.");
|
||||
setDeletingMemberId(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete member.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Alumni Handlers
|
||||
const handleOpenAlumniCreate = () => {
|
||||
setEditingAlumnus(null);
|
||||
setAlumniForm({
|
||||
name_ko: "",
|
||||
name_en: "",
|
||||
degree: "MS",
|
||||
graduated_at: "",
|
||||
major: "",
|
||||
current_position: "",
|
||||
});
|
||||
setIsAlumniModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenAlumniEdit = (a: AdminAlumnus) => {
|
||||
setEditingAlumnus(a);
|
||||
setAlumniForm({
|
||||
name_ko: a.name_ko,
|
||||
name_en: a.name_en,
|
||||
degree: a.degree,
|
||||
graduated_at: a.graduated_at,
|
||||
major: a.major || "",
|
||||
current_position: a.current_position || "",
|
||||
});
|
||||
setIsAlumniModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAlumniSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
const payload = {
|
||||
name_ko: alumniForm.name_ko.trim(),
|
||||
name_en: alumniForm.name_en.trim(),
|
||||
degree: alumniForm.degree,
|
||||
graduated_at: alumniForm.graduated_at.trim(),
|
||||
major: alumniForm.major.trim() || undefined,
|
||||
current_position: alumniForm.current_position.trim() || undefined,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingAlumnus) {
|
||||
await adminUpdateAlumnus(token, editingAlumnus.id, payload);
|
||||
setSuccessMsg(`Alumnus "${payload.name_ko}" updated successfully.`);
|
||||
} else {
|
||||
await adminCreateAlumnus(token, payload);
|
||||
setSuccessMsg(`Alumnus "${payload.name_ko}" created successfully.`);
|
||||
}
|
||||
setIsAlumniModalOpen(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save alumnus.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteAlumnus = async () => {
|
||||
if (!deletingAlumniId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteAlumnus(token, deletingAlumniId);
|
||||
setSuccessMsg("Alumnus deleted successfully.");
|
||||
setDeletingAlumniId(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete alumnus.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Members & Alumni Management"
|
||||
description="Manage active laboratory researchers, advisors, and graduated alumni."
|
||||
action={
|
||||
<button
|
||||
onClick={tab === "active" ? handleOpenMemberCreate : handleOpenAlumniCreate}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
||||
>
|
||||
{tab === "active" ? "+ Add Active Member" : "+ Add Alumnus"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-line mb-6 gap-6">
|
||||
<button
|
||||
onClick={() => setTab("active")}
|
||||
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
|
||||
tab === "active" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
Active Members ({members.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("alumni")}
|
||||
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
|
||||
tab === "alumni" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
Alumni ({alumni.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading records...</div>
|
||||
) : tab === "active" ? (
|
||||
/* Active Members Table */
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-16">Order</th>
|
||||
<th className="p-4">Name (Ko / En)</th>
|
||||
<th className="p-4">Degree</th>
|
||||
<th className="p-4">Affiliation / Email</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{members.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-6 text-center text-ink/60">
|
||||
No active members found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/60">{m.display_order}</td>
|
||||
<td className="p-4">
|
||||
<div className="font-semibold text-ink flex items-center gap-2">
|
||||
{m.name_ko} ({m.name_en})
|
||||
{m.is_advisor && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-cobalt/10 text-cobalt font-bold">
|
||||
Advisor
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-xs font-medium text-ink/80">{m.degree}</td>
|
||||
<td className="p-4 text-xs text-ink/70">
|
||||
<div>{m.affiliation || "—"}</div>
|
||||
<div className="text-ink/50">{m.email || "—"}</div>
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenMemberEdit(m)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingMemberId(m.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
/* Alumni Table */
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4">Graduation Date</th>
|
||||
<th className="p-4">Name (Ko / En)</th>
|
||||
<th className="p-4">Degree</th>
|
||||
<th className="p-4">Major / Current Position</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{alumni.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-6 text-center text-ink/60">
|
||||
No alumni found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
alumni.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/80">{a.graduated_at}</td>
|
||||
<td className="p-4 font-semibold text-ink">
|
||||
{a.name_ko} ({a.name_en})
|
||||
</td>
|
||||
<td className="p-4 text-xs font-medium text-ink/80">{a.degree}</td>
|
||||
<td className="p-4 text-xs text-ink/70">
|
||||
<div>{a.current_position || "—"}</div>
|
||||
<div className="text-ink/50">{a.major || "—"}</div>
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenAlumniEdit(a)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingAlumniId(a.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Modal */}
|
||||
<Modal
|
||||
isOpen={isMemberModalOpen}
|
||||
title={editingMember ? "Edit Active Member" : "Create Active Member"}
|
||||
onClose={() => setIsMemberModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleMemberSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Korean Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memberForm.name_ko}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, name_ko: e.target.value })}
|
||||
placeholder="e.g. 홍길동"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memberForm.name_en}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, name_en: e.target.value })}
|
||||
placeholder="e.g. Gildong Hong"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Degree Level *</label>
|
||||
<select
|
||||
value={memberForm.degree}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, degree: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
{MEMBER_DEGREES.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={memberForm.display_order}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, display_order: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Affiliation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memberForm.affiliation}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, affiliation: e.target.value })}
|
||||
placeholder="e.g. School of Electronic Engineering"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={memberForm.email}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, email: e.target.value })}
|
||||
placeholder="e.g. user@knu.ac.kr"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_advisor"
|
||||
checked={memberForm.is_advisor}
|
||||
onChange={(e) => setMemberForm({ ...memberForm, is_advisor: e.target.checked })}
|
||||
className="rounded text-cobalt focus:ring-cobalt"
|
||||
/>
|
||||
<label htmlFor="is_advisor" className="text-xs font-semibold text-ink">
|
||||
Principal Investigator / Lab Advisor
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMemberModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingMember ? "Save Changes" : "Create Member"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Alumni Modal */}
|
||||
<Modal
|
||||
isOpen={isAlumniModalOpen}
|
||||
title={editingAlumnus ? "Edit Alumnus" : "Create Alumnus"}
|
||||
onClose={() => setIsAlumniModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleAlumniSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Korean Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alumniForm.name_ko}
|
||||
onChange={(e) => setAlumniForm({ ...alumniForm, name_ko: e.target.value })}
|
||||
placeholder="e.g. 홍길동"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alumniForm.name_en}
|
||||
onChange={(e) => setAlumniForm({ ...alumniForm, name_en: e.target.value })}
|
||||
placeholder="e.g. Gildong Hong"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Graduation Date (ISO 8601) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alumniForm.graduated_at}
|
||||
onChange={(e) => setAlumniForm({ ...alumniForm, graduated_at: e.target.value })}
|
||||
placeholder="e.g. 2024-02"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Degree *</label>
|
||||
<select
|
||||
value={alumniForm.degree}
|
||||
onChange={(e) => setAlumniForm({ ...alumniForm, degree: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
{ALUMNI_DEGREES.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Current Position</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alumniForm.current_position}
|
||||
onChange={(e) => setAlumniForm({ ...alumniForm, current_position: e.target.value })}
|
||||
placeholder="e.g. Samsung Electronics, Principal Engineer"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAlumniModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingAlumnus ? "Save Changes" : "Create Alumnus"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingMemberId !== null}
|
||||
onClose={() => setDeletingMemberId(null)}
|
||||
onConfirm={handleConfirmDeleteMember}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Member"
|
||||
description="Are you sure you want to delete this member?"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingAlumniId !== null}
|
||||
onClose={() => setDeletingAlumniId(null)}
|
||||
onConfirm={handleConfirmDeleteAlumnus}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Alumnus"
|
||||
description="Are you sure you want to delete this alumnus?"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AdminHeader, AlertBanner } from "./_components/AdminComponents";
|
||||
import {
|
||||
adminGetResearchProjects,
|
||||
adminGetResearchAreas,
|
||||
adminGetMembers,
|
||||
adminGetAlumni,
|
||||
adminGetPublications,
|
||||
adminGetPatents,
|
||||
adminGetSemesters,
|
||||
adminGetStandardsBodies,
|
||||
} from "../../lib/adminApi";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const [counts, setCounts] = useState<{
|
||||
projects: number;
|
||||
areas: number;
|
||||
members: number;
|
||||
alumni: number;
|
||||
intlPubs: number;
|
||||
domPubs: number;
|
||||
patents: number;
|
||||
semesters: number;
|
||||
courses: number;
|
||||
bodies: number;
|
||||
docs: number;
|
||||
}>({
|
||||
projects: 0,
|
||||
areas: 0,
|
||||
members: 0,
|
||||
alumni: 0,
|
||||
intlPubs: 0,
|
||||
domPubs: 0,
|
||||
patents: 0,
|
||||
semesters: 0,
|
||||
courses: 0,
|
||||
bodies: 0,
|
||||
docs: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [
|
||||
projects,
|
||||
areas,
|
||||
members,
|
||||
alumni,
|
||||
intlPubs,
|
||||
domPubs,
|
||||
patents,
|
||||
semesters,
|
||||
bodies,
|
||||
] = await Promise.all([
|
||||
adminGetResearchProjects().catch(() => []),
|
||||
adminGetResearchAreas().catch(() => []),
|
||||
adminGetMembers().catch(() => []),
|
||||
adminGetAlumni().catch(() => []),
|
||||
adminGetPublications("intl-journal-conf").catch(() => []),
|
||||
adminGetPublications("domestic-journal-conf").catch(() => []),
|
||||
adminGetPatents().catch(() => []),
|
||||
adminGetSemesters().catch(() => []),
|
||||
adminGetStandardsBodies().catch(() => []),
|
||||
]);
|
||||
|
||||
const safeProjects = projects || [];
|
||||
const safeAreas = areas || [];
|
||||
const safeMembers = members || [];
|
||||
const safeAlumni = alumni || [];
|
||||
const safeIntlPubs = intlPubs || [];
|
||||
const safeDomPubs = domPubs || [];
|
||||
const safePatents = patents || [];
|
||||
const safeSemesters = semesters || [];
|
||||
const safeBodies = bodies || [];
|
||||
|
||||
const totalCourses = safeSemesters.reduce((acc, s) => acc + (s?.courses?.length || 0), 0);
|
||||
const totalDocs = safeBodies.reduce(
|
||||
(acc, b) => acc + ((b?.projects || []).reduce((pAcc, p) => pAcc + (p?.documents?.length || 0), 0) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
setCounts({
|
||||
projects: safeProjects.length,
|
||||
areas: safeAreas.length,
|
||||
members: safeMembers.length,
|
||||
alumni: safeAlumni.length,
|
||||
intlPubs: safeIntlPubs.length,
|
||||
domPubs: safeDomPubs.length,
|
||||
patents: safePatents.length,
|
||||
semesters: safeSemesters.length,
|
||||
courses: totalCourses,
|
||||
bodies: safeBodies.length,
|
||||
docs: totalDocs,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load dashboard data from backend API.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Research Projects",
|
||||
count: counts.projects,
|
||||
sub: `${counts.areas} Research Areas`,
|
||||
href: "/console/research-projects",
|
||||
color: "border-cobalt/30 hover:border-cobalt",
|
||||
},
|
||||
{
|
||||
title: "Members & Alumni",
|
||||
count: counts.members,
|
||||
sub: `${counts.alumni} Alumni Records`,
|
||||
href: "/console/members",
|
||||
color: "border-emerald-500/30 hover:border-emerald-500",
|
||||
},
|
||||
{
|
||||
title: "Publications & Patents",
|
||||
count: counts.intlPubs + counts.domPubs,
|
||||
sub: `${counts.intlPubs} Intl / ${counts.domPubs} Dom / ${counts.patents} Patents`,
|
||||
href: "/console/publications",
|
||||
color: "border-blue-500/30 hover:border-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Lectures & Courses",
|
||||
count: counts.semesters,
|
||||
sub: `${counts.courses} Total Courses across Semesters`,
|
||||
href: "/console/lectures",
|
||||
color: "border-amber-500/30 hover:border-amber-500",
|
||||
},
|
||||
{
|
||||
title: "Standardization",
|
||||
count: counts.bodies,
|
||||
sub: `${counts.docs} Standard Documents`,
|
||||
href: "/console/standardization",
|
||||
color: "border-purple-500/30 hover:border-purple-500",
|
||||
},
|
||||
{
|
||||
title: "Research Areas",
|
||||
count: counts.areas,
|
||||
sub: "Active Laboratory Focus Areas",
|
||||
href: "/console/research-areas",
|
||||
color: "border-indigo-500/30 hover:border-indigo-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Administrative Overview"
|
||||
description="Live status of all ANL laboratory records and datasets stored in the backend."
|
||||
/>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading dashboard statistics...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
className={`p-6 bg-paper rounded-lg border ${card.color} transition-all shadow-sm flex flex-col justify-between`}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-ink">{card.title}</h2>
|
||||
<div className="text-3xl font-extrabold text-cobalt mt-3">{card.count}</div>
|
||||
<p className="text-xs text-ink/70 mt-1">{card.sub}</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-line text-xs font-medium text-cobalt flex items-center justify-between">
|
||||
<span>Manage Records</span>
|
||||
<span>→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
||||
import {
|
||||
adminGetPublications,
|
||||
adminCreatePublication,
|
||||
adminUpdatePublication,
|
||||
adminDeletePublication,
|
||||
adminGetPatents,
|
||||
adminCreatePatent,
|
||||
adminUpdatePatent,
|
||||
adminDeletePatent,
|
||||
AdminPublication,
|
||||
AdminPatent,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function AdminPublicationsPage() {
|
||||
const [tab, setTab] = useState<"publications" | "patents">("publications");
|
||||
const [publications, setPublications] = useState<AdminPublication[]>([]);
|
||||
const [patents, setPatents] = useState<AdminPatent[]>([]);
|
||||
const [filterCategory, setFilterCategory] = useState<string>("all");
|
||||
const [filterYear, setFilterYear] = useState<string>("all");
|
||||
const [filterPatentYear, setFilterPatentYear] = useState<string>("all");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Publication Modal State
|
||||
const [isPubModalOpen, setIsPubModalOpen] = useState(false);
|
||||
const [editingPub, setEditingPub] = useState<AdminPublication | null>(null);
|
||||
const [pubForm, setPubForm] = useState({
|
||||
category: "intl-journal-conf" as "intl-journal-conf" | "domestic-journal-conf",
|
||||
title: "",
|
||||
authors: "",
|
||||
venue: "",
|
||||
volume: "",
|
||||
published_at: "",
|
||||
kci: false,
|
||||
doi: "",
|
||||
is_highlight: false,
|
||||
});
|
||||
|
||||
// Patent Modal State
|
||||
const [isPatentModalOpen, setIsPatentModalOpen] = useState(false);
|
||||
const [editingPatent, setEditingPatent] = useState<AdminPatent | null>(null);
|
||||
const [patentForm, setPatentForm] = useState({
|
||||
title: "",
|
||||
inventors: "",
|
||||
application_no: "",
|
||||
application_at: "",
|
||||
registration_no: "",
|
||||
registration_at: "",
|
||||
country: "Korea",
|
||||
published_at: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingPubId, setDeletingPubId] = useState<number | null>(null);
|
||||
const [deletingPatentId, setDeletingPatentId] = useState<number | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const [pubList, patList] = await Promise.all([adminGetPublications(), adminGetPatents()]);
|
||||
setPublications(pubList);
|
||||
setPatents(patList);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load publications.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Pub Handlers
|
||||
const handleOpenPubCreate = () => {
|
||||
setEditingPub(null);
|
||||
setPubForm({
|
||||
category: "intl-journal-conf",
|
||||
title: "",
|
||||
authors: "",
|
||||
venue: "",
|
||||
volume: "",
|
||||
published_at: new Date().getFullYear().toString(),
|
||||
kci: false,
|
||||
doi: "",
|
||||
is_highlight: false,
|
||||
});
|
||||
setIsPubModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenPubEdit = (p: AdminPublication) => {
|
||||
setEditingPub(p);
|
||||
setPubForm({
|
||||
category: p.category,
|
||||
title: p.title,
|
||||
authors: p.authors || "",
|
||||
venue: p.venue,
|
||||
volume: p.volume || "",
|
||||
published_at: p.published_at,
|
||||
kci: p.kci,
|
||||
doi: p.doi || "",
|
||||
is_highlight: p.is_highlight,
|
||||
});
|
||||
setIsPubModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePubSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
const payload = {
|
||||
category: pubForm.category,
|
||||
title: pubForm.title.trim(),
|
||||
authors: pubForm.authors.trim() || undefined,
|
||||
venue: pubForm.venue.trim(),
|
||||
volume: pubForm.volume.trim() || undefined,
|
||||
published_at: pubForm.published_at.trim(),
|
||||
kci: pubForm.kci,
|
||||
doi: pubForm.doi.trim() || undefined,
|
||||
is_highlight: pubForm.is_highlight,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingPub) {
|
||||
await adminUpdatePublication(token, editingPub.id, payload);
|
||||
setSuccessMsg(`Publication "${payload.title}" updated.`);
|
||||
} else {
|
||||
await adminCreatePublication(token, payload);
|
||||
setSuccessMsg(`Publication "${payload.title}" created.`);
|
||||
}
|
||||
setIsPubModalOpen(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save publication.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeletePub = async () => {
|
||||
if (!deletingPubId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeletePublication(token, deletingPubId);
|
||||
setSuccessMsg("Publication deleted successfully.");
|
||||
setDeletingPubId(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete publication.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Patent Handlers
|
||||
const handleOpenPatentCreate = () => {
|
||||
setEditingPatent(null);
|
||||
setPatentForm({
|
||||
title: "",
|
||||
inventors: "",
|
||||
application_no: "",
|
||||
application_at: "",
|
||||
registration_no: "",
|
||||
registration_at: "",
|
||||
country: "Korea",
|
||||
published_at: new Date().getFullYear().toString(),
|
||||
});
|
||||
setIsPatentModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenPatentEdit = (p: AdminPatent) => {
|
||||
setEditingPatent(p);
|
||||
setPatentForm({
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
application_no: p.application_no,
|
||||
application_at: p.application_at,
|
||||
registration_no: p.registration_no || "",
|
||||
registration_at: p.registration_at || "",
|
||||
country: p.country,
|
||||
published_at: p.published_at,
|
||||
});
|
||||
setIsPatentModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePatentSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
const payload = {
|
||||
title: patentForm.title.trim(),
|
||||
inventors: patentForm.inventors.trim(),
|
||||
application_no: patentForm.application_no.trim(),
|
||||
application_at: patentForm.application_at.trim(),
|
||||
registration_no: patentForm.registration_no.trim() || undefined,
|
||||
registration_at: patentForm.registration_at.trim() || undefined,
|
||||
country: patentForm.country.trim(),
|
||||
published_at: patentForm.published_at.trim(),
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingPatent) {
|
||||
await adminUpdatePatent(token, editingPatent.id, payload);
|
||||
setSuccessMsg(`Patent "${payload.title}" updated.`);
|
||||
} else {
|
||||
await adminCreatePatent(token, payload);
|
||||
setSuccessMsg(`Patent "${payload.title}" created.`);
|
||||
}
|
||||
setIsPatentModalOpen(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save patent.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeletePatent = async () => {
|
||||
if (!deletingPatentId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeletePatent(token, deletingPatentId);
|
||||
setSuccessMsg("Patent deleted successfully.");
|
||||
setDeletingPatentId(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete patent.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract unique sorted years from publications
|
||||
const pubYears = Array.from(
|
||||
new Set(
|
||||
publications
|
||||
.map((p) => {
|
||||
const match = (p.published_at || "").match(/\b(19\d\d|20\d\d)\b/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// Extract unique sorted years from patents
|
||||
const patentYears = Array.from(
|
||||
new Set(
|
||||
patents
|
||||
.map((p) => {
|
||||
const dateStr = p.published_at || p.application_at || p.registration_at || "";
|
||||
const match = dateStr.match(/\b(19\d\d|20\d\d)\b/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const filteredPubs = publications.filter((p) => {
|
||||
const matchCategory = filterCategory === "all" || p.category === filterCategory;
|
||||
const pubYear = (p.published_at || "").match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||
const matchYear = filterYear === "all" || pubYear === filterYear;
|
||||
return matchCategory && matchYear;
|
||||
});
|
||||
|
||||
const filteredPatents = patents.filter((p) => {
|
||||
if (filterPatentYear === "all") return true;
|
||||
const dateStr = p.published_at || p.application_at || p.registration_at || "";
|
||||
const patYear = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||
return patYear === filterPatentYear;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Publications & Patents Management"
|
||||
description="Manage journal articles, conference proceedings, and registered laboratory patents."
|
||||
action={
|
||||
<button
|
||||
onClick={tab === "publications" ? handleOpenPubCreate : handleOpenPatentCreate}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
||||
>
|
||||
{tab === "publications" ? "+ Add Publication" : "+ Add Patent"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-line mb-6 gap-6">
|
||||
<button
|
||||
onClick={() => setTab("publications")}
|
||||
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
|
||||
tab === "publications" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
Publications ({publications.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("patents")}
|
||||
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
|
||||
tab === "patents" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
Patents ({patents.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading records...</div>
|
||||
) : tab === "publications" ? (
|
||||
<div>
|
||||
{/* Filter sub-bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Category:</span>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">
|
||||
All Categories ({publications.filter((p) => filterYear === "all" || (p.published_at || "").includes(filterYear)).length})
|
||||
</option>
|
||||
<option value="intl-journal-conf">
|
||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
|
||||
</option>
|
||||
<option value="domestic-journal-conf">
|
||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf" && (filterYear === "all" || (p.published_at || "").includes(filterYear))).length})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Year:</span>
|
||||
<select
|
||||
value={filterYear}
|
||||
onChange={(e) => setFilterYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">
|
||||
All Years ({publications.filter((p) => filterCategory === "all" || p.category === filterCategory).length})
|
||||
</option>
|
||||
{pubYears.map((yr) => {
|
||||
const count = publications.filter((p) => {
|
||||
const matchCat = filterCategory === "all" || p.category === filterCategory;
|
||||
const matchYr = (p.published_at || "").includes(yr);
|
||||
return matchCat && matchYr;
|
||||
}).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(filterCategory !== "all" || filterYear !== "all") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterCategory("all");
|
||||
setFilterYear("all");
|
||||
}}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-ink/50 ml-auto">
|
||||
Showing {filteredPubs.length} of {publications.length} records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-28">Date</th>
|
||||
<th className="p-4">Title & Venue</th>
|
||||
<th className="p-4 w-36">Category</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{filteredPubs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No publications found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPubs.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
|
||||
<td className="p-4">
|
||||
<div className="font-semibold text-ink flex items-center gap-2">
|
||||
{p.title}
|
||||
{p.is_highlight && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold">
|
||||
Highlight
|
||||
</span>
|
||||
)}
|
||||
{p.kci && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold">
|
||||
KCI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-ink/70 mt-0.5">
|
||||
{p.venue} {p.volume && `· ${p.volume}`}
|
||||
</div>
|
||||
{p.authors && <div className="text-xs text-ink/50 mt-0.5">{p.authors}</div>}
|
||||
</td>
|
||||
<td className="p-4 text-xs text-ink/70">
|
||||
{p.category === "intl-journal-conf" ? "International" : "Domestic"}
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenPubEdit(p)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingPubId(p.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Patents Table */
|
||||
<div>
|
||||
{/* Filter sub-bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Year:</span>
|
||||
<select
|
||||
value={filterPatentYear}
|
||||
onChange={(e) => setFilterPatentYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">All Years ({patents.length})</option>
|
||||
{patentYears.map((yr) => {
|
||||
const count = patents.filter((p) => {
|
||||
const d = p.published_at || p.application_at || p.registration_at || "";
|
||||
return d.includes(yr);
|
||||
}).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filterPatentYear !== "all" && (
|
||||
<button
|
||||
onClick={() => setFilterPatentYear("all")}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset filter
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-ink/50 ml-auto">
|
||||
Showing {filteredPatents.length} of {patents.length} records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-28">Date</th>
|
||||
<th className="p-4">Title & Inventors</th>
|
||||
<th className="p-4">App / Reg Numbers</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{filteredPatents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No patents found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPatents.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
|
||||
<td className="p-4">
|
||||
<div className="font-semibold text-ink">{p.title}</div>
|
||||
<div className="text-xs text-ink/60 mt-0.5">{p.inventors}</div>
|
||||
</td>
|
||||
<td className="p-4 text-xs text-ink/80">
|
||||
<div>App: {p.application_no} ({p.application_at})</div>
|
||||
{p.registration_no && (
|
||||
<div className="text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Reg: {p.registration_no} ({p.registration_at || "—"})
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenPatentEdit(p)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingPatentId(p.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pub Modal */}
|
||||
<Modal
|
||||
isOpen={isPubModalOpen}
|
||||
title={editingPub ? "Edit Publication" : "Create Publication"}
|
||||
onClose={() => setIsPubModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handlePubSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Category *</label>
|
||||
<select
|
||||
value={pubForm.category}
|
||||
onChange={(e) =>
|
||||
setPubForm({
|
||||
...pubForm,
|
||||
category: e.target.value as "intl-journal-conf" | "domestic-journal-conf",
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="intl-journal-conf">International Journal / Conference</option>
|
||||
<option value="domestic-journal-conf">Domestic Journal / Conference</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Published Date (ISO 8601) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pubForm.published_at}
|
||||
onChange={(e) => setPubForm({ ...pubForm, published_at: e.target.value })}
|
||||
placeholder="e.g. 2023-11"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pubForm.title}
|
||||
onChange={(e) => setPubForm({ ...pubForm, title: e.target.value })}
|
||||
placeholder="Full paper title"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Authors</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pubForm.authors}
|
||||
onChange={(e) => setPubForm({ ...pubForm, authors: e.target.value })}
|
||||
placeholder="e.g. Gildong Hong, Prof. Advisor"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Venue (Journal / Conference) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pubForm.venue}
|
||||
onChange={(e) => setPubForm({ ...pubForm, venue: e.target.value })}
|
||||
placeholder="e.g. IEEE Transactions on Communications"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Volume / Pages</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pubForm.volume}
|
||||
onChange={(e) => setPubForm({ ...pubForm, volume: e.target.value })}
|
||||
placeholder="e.g. Vol. 70, No. 3, pp. 120-135"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">DOI (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pubForm.doi}
|
||||
onChange={(e) => setPubForm({ ...pubForm, doi: e.target.value })}
|
||||
placeholder="e.g. 10.1109/TCOMM.2023.123456"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-5">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-ink cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pubForm.is_highlight}
|
||||
onChange={(e) => setPubForm({ ...pubForm, is_highlight: e.target.checked })}
|
||||
className="rounded text-cobalt focus:ring-cobalt"
|
||||
/>
|
||||
Home Highlight
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-ink cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pubForm.kci}
|
||||
onChange={(e) => setPubForm({ ...pubForm, kci: e.target.checked })}
|
||||
className="rounded text-cobalt focus:ring-cobalt"
|
||||
/>
|
||||
KCI Indexed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPubModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingPub ? "Save Changes" : "Create Publication"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Patent Modal */}
|
||||
<Modal
|
||||
isOpen={isPatentModalOpen}
|
||||
title={editingPatent ? "Edit Patent" : "Create Patent"}
|
||||
onClose={() => setIsPatentModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handlePatentSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Patent Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.title}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, title: e.target.value })}
|
||||
placeholder="e.g. Method and system for optical camera communications"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Inventors *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.inventors}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, inventors: e.target.value })}
|
||||
placeholder="e.g. Gildong Hong, Advisor"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.country}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, country: e.target.value })}
|
||||
placeholder="e.g. Korea or USA"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Application No. *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.application_no}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, application_no: e.target.value })}
|
||||
placeholder="e.g. 10-2023-0123456"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Application Date *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.application_at}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, application_at: e.target.value })}
|
||||
placeholder="e.g. 2023-05-12"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Registration No. (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.registration_no}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, registration_no: e.target.value })}
|
||||
placeholder="e.g. 10-2543210"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Registration Date</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.registration_at}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, registration_at: e.target.value })}
|
||||
placeholder="e.g. 2024-01-10"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Primary Display Year/Date *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patentForm.published_at}
|
||||
onChange={(e) => setPatentForm({ ...patentForm, published_at: e.target.value })}
|
||||
placeholder="e.g. 2023"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPatentModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingPatent ? "Save Changes" : "Create Patent"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingPubId !== null}
|
||||
onClose={() => setDeletingPubId(null)}
|
||||
onConfirm={handleConfirmDeletePub}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Publication"
|
||||
description="Are you sure you want to delete this publication?"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingPatentId !== null}
|
||||
onClose={() => setDeletingPatentId(null)}
|
||||
onConfirm={handleConfirmDeletePatent}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Patent"
|
||||
description="Are you sure you want to delete this patent record?"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
||||
import {
|
||||
adminGetResearchAreas,
|
||||
adminCreateResearchArea,
|
||||
adminUpdateResearchArea,
|
||||
adminDeleteResearchArea,
|
||||
AdminResearchArea,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function AdminResearchAreasPage() {
|
||||
const [areas, setAreas] = useState<AdminResearchArea[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingArea, setEditingArea] = useState<AdminResearchArea | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name_en: "",
|
||||
name_kr: "",
|
||||
display_order: 0,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
const loadAreas = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await adminGetResearchAreas();
|
||||
setAreas(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load research areas.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAreas();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingArea(null);
|
||||
setFormData({
|
||||
name_en: "",
|
||||
name_kr: "",
|
||||
display_order: areas.length + 1,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (a: AdminResearchArea) => {
|
||||
setEditingArea(a);
|
||||
setFormData({
|
||||
name_en: a.name_en,
|
||||
name_kr: a.name_kr || "",
|
||||
display_order: a.display_order,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
setError("Unauthorized. Please log in.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name_en: formData.name_en.trim(),
|
||||
name_kr: formData.name_kr.trim() || undefined,
|
||||
display_order: Number(formData.display_order) || 0,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingArea) {
|
||||
await adminUpdateResearchArea(token, editingArea.id, payload);
|
||||
setSuccessMsg(`Research Area "${payload.name_en}" updated successfully.`);
|
||||
} else {
|
||||
await adminCreateResearchArea(token, payload);
|
||||
setSuccessMsg(`Research Area "${payload.name_en}" created successfully.`);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
await loadAreas();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save research area.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
setError("Unauthorized. Please log in.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteResearchArea(token, deletingId);
|
||||
setSuccessMsg("Research Area deleted successfully.");
|
||||
setDeletingId(null);
|
||||
await loadAreas();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete research area.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Research Areas"
|
||||
description="Manage the key laboratory research area keywords listed in the overview section."
|
||||
action={
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
||||
>
|
||||
+ Add New Area
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading research areas...</div>
|
||||
) : (
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-16">Order</th>
|
||||
<th className="p-4">English Name</th>
|
||||
<th className="p-4">Korean Name</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{areas.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No research areas found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
areas.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/60">{a.display_order}</td>
|
||||
<td className="p-4 font-semibold text-ink">{a.name_en}</td>
|
||||
<td className="p-4 text-ink/80">{a.name_kr || "—"}</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(a)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(a.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
title={editingArea ? "Edit Research Area" : "Create Research Area"}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name_en}
|
||||
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
|
||||
placeholder="e.g. Optical Wireless Communications"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Korean Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name_kr}
|
||||
onChange={(e) => setFormData({ ...formData, name_kr: e.target.value })}
|
||||
placeholder="e.g. 광무선 통신"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.display_order}
|
||||
onChange={(e) => setFormData({ ...formData, display_order: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingArea ? "Save Changes" : "Create Area"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingId !== null}
|
||||
onClose={() => setDeletingId(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Research Area"
|
||||
description="Are you sure you want to delete this research area keyword?"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
||||
import {
|
||||
adminGetResearchProjects,
|
||||
adminCreateResearchProject,
|
||||
adminUpdateResearchProject,
|
||||
adminDeleteResearchProject,
|
||||
AdminResearchProject,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function AdminResearchProjectsPage() {
|
||||
const [projects, setProjects] = useState<AdminResearchProject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Form State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<AdminResearchProject | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
slug: "",
|
||||
title: "",
|
||||
abstract: "",
|
||||
keywords: "",
|
||||
organization: "",
|
||||
standards_org: "",
|
||||
period: "",
|
||||
funder: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Delete State
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await adminGetResearchProjects();
|
||||
setProjects(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load research projects.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingProject(null);
|
||||
setFormData({
|
||||
slug: "",
|
||||
title: "",
|
||||
abstract: "",
|
||||
keywords: "",
|
||||
organization: "",
|
||||
standards_org: "",
|
||||
period: "",
|
||||
funder: "",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (p: AdminResearchProject) => {
|
||||
setEditingProject(p);
|
||||
setFormData({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
abstract: p.abstract,
|
||||
keywords: (p.keywords || []).join(", "),
|
||||
organization: p.organization || "",
|
||||
standards_org: p.standards_org || "",
|
||||
period: p.period || "",
|
||||
funder: p.funder || "",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
setError("Unauthorized. Please log in.");
|
||||
return;
|
||||
}
|
||||
|
||||
const kwArray = formData.keywords
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const payload = {
|
||||
slug: formData.slug.trim(),
|
||||
title: formData.title.trim(),
|
||||
abstract: formData.abstract.trim(),
|
||||
keywords: kwArray,
|
||||
organization: formData.organization.trim() || undefined,
|
||||
standards_org: formData.standards_org.trim() || undefined,
|
||||
period: formData.period.trim() || undefined,
|
||||
funder: formData.funder.trim() || undefined,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingProject) {
|
||||
await adminUpdateResearchProject(token, editingProject.id, payload);
|
||||
setSuccessMsg(`Project "${payload.title}" updated successfully.`);
|
||||
} else {
|
||||
await adminCreateResearchProject(token, payload);
|
||||
setSuccessMsg(`Project "${payload.title}" created successfully.`);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
await loadProjects();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save project.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) {
|
||||
setError("Unauthorized. Please log in.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteResearchProject(token, deletingId);
|
||||
setSuccessMsg("Project deleted successfully.");
|
||||
setDeletingId(null);
|
||||
await loadProjects();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete project.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Research Projects"
|
||||
description="Manage key laboratory research projects displayed on the home page."
|
||||
action={
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
||||
>
|
||||
+ Add New Project
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading projects...</div>
|
||||
) : (
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4">ID</th>
|
||||
<th className="p-4">Slug</th>
|
||||
<th className="p-4">Title</th>
|
||||
<th className="p-4">Organization / Period</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{projects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-6 text-center text-ink/60">
|
||||
No research projects found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/60">{p.id}</td>
|
||||
<td className="p-4 font-mono text-xs text-cobalt font-medium">{p.slug}</td>
|
||||
<td className="p-4">
|
||||
<div className="font-semibold text-ink">{p.title}</div>
|
||||
<div className="text-xs text-ink/60 line-clamp-1 mt-0.5">{p.abstract}</div>
|
||||
</td>
|
||||
<td className="p-4 text-xs text-ink/80">
|
||||
<div>{p.organization || p.standards_org || "—"}</div>
|
||||
<div className="text-ink/60">{p.period || "—"}</div>
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(p)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(p.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
title={editingProject ? "Edit Research Project" : "Create Research Project"}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Slug *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="e.g. ccis"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Project title"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Abstract *</label>
|
||||
<textarea
|
||||
value={formData.abstract}
|
||||
onChange={(e) => setFormData({ ...formData, abstract: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Detailed description of the project"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Keywords (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.keywords}
|
||||
onChange={(e) => setFormData({ ...formData, keywords: e.target.value })}
|
||||
placeholder="e.g. ISO/IEC 63246, Camera Communications"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Organization / Standards Track</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.organization}
|
||||
onChange={(e) => setFormData({ ...formData, organization: e.target.value })}
|
||||
placeholder="e.g. ISO/IEC JTC 1/SC 29/WG 11"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Period</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.period}
|
||||
onChange={(e) => setFormData({ ...formData, period: e.target.value })}
|
||||
placeholder="e.g. 2021.06 - 2024.05"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Funder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.funder}
|
||||
onChange={(e) => setFormData({ ...formData, funder: e.target.value })}
|
||||
placeholder="e.g. Ministry of Science and ICT (MSIT)"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingProject ? "Save Changes" : "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingId !== null}
|
||||
onClose={() => setDeletingId(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Research Project"
|
||||
description="Are you sure you want to delete this research project? It will be removed from the public website immediately."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
||||
import {
|
||||
adminGetStandardsBodies,
|
||||
adminCreateStandardsBody,
|
||||
adminUpdateStandardsBody,
|
||||
adminDeleteStandardsBody,
|
||||
adminCreateStandardDocument,
|
||||
adminUpdateStandardDocument,
|
||||
adminDeleteStandardDocument,
|
||||
AdminStandardsBody,
|
||||
AdminStandardDocument,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
const DOC_STATUSES = ["IS", "TS", "TR", "CDV", "WDTR", "InProgress"];
|
||||
|
||||
export default function AdminStandardizationPage() {
|
||||
const [bodies, setBodies] = useState<AdminStandardsBody[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Body Modal State
|
||||
const [isBodyModalOpen, setIsBodyModalOpen] = useState(false);
|
||||
const [editingBody, setEditingBody] = useState<AdminStandardsBody | null>(null);
|
||||
const [bodyForm, setBodyForm] = useState({
|
||||
org: "",
|
||||
full_name: "",
|
||||
scope: "international" as "international" | "domestic",
|
||||
period: "",
|
||||
role: "",
|
||||
display_order: 0,
|
||||
});
|
||||
|
||||
// Document Modal State
|
||||
const [isDocModalOpen, setIsDocModalOpen] = useState(false);
|
||||
const [targetBodyId, setTargetBodyId] = useState<number | null>(null);
|
||||
const [editingDoc, setEditingDoc] = useState<AdminStandardDocument | null>(null);
|
||||
const [docForm, setDocForm] = useState({
|
||||
wg: "",
|
||||
project_name: "",
|
||||
title: "",
|
||||
doc_ref: "",
|
||||
status: "IS" as "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress",
|
||||
published_at: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingBodyId, setDeletingBodyId] = useState<number | null>(null);
|
||||
const [deletingDocId, setDeletingDocId] = useState<number | null>(null);
|
||||
|
||||
const loadBodies = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await adminGetStandardsBodies();
|
||||
setBodies(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to load standards bodies.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBodies();
|
||||
}, []);
|
||||
|
||||
// Body Actions
|
||||
const handleOpenBodyCreate = () => {
|
||||
setEditingBody(null);
|
||||
setBodyForm({
|
||||
org: "",
|
||||
full_name: "",
|
||||
scope: "international",
|
||||
period: "",
|
||||
role: "",
|
||||
display_order: bodies.length + 1,
|
||||
});
|
||||
setIsBodyModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenBodyEdit = (b: AdminStandardsBody) => {
|
||||
setEditingBody(b);
|
||||
setBodyForm({
|
||||
org: b.org,
|
||||
full_name: b.full_name,
|
||||
scope: b.scope,
|
||||
period: b.period,
|
||||
role: b.role || "",
|
||||
display_order: b.display_order,
|
||||
});
|
||||
setIsBodyModalOpen(true);
|
||||
};
|
||||
|
||||
const handleBodySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
const payload = {
|
||||
org: bodyForm.org.trim(),
|
||||
full_name: bodyForm.full_name.trim(),
|
||||
scope: bodyForm.scope,
|
||||
period: bodyForm.period.trim(),
|
||||
role: bodyForm.role.trim() || undefined,
|
||||
display_order: Number(bodyForm.display_order) || 0,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingBody) {
|
||||
await adminUpdateStandardsBody(token, editingBody.id, payload);
|
||||
setSuccessMsg(`Standards Body "${payload.org}" updated.`);
|
||||
} else {
|
||||
await adminCreateStandardsBody(token, payload);
|
||||
setSuccessMsg(`Standards Body "${payload.org}" created.`);
|
||||
}
|
||||
setIsBodyModalOpen(false);
|
||||
await loadBodies();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save standards body.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteBody = async () => {
|
||||
if (!deletingBodyId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteStandardsBody(token, deletingBodyId);
|
||||
setSuccessMsg("Standards body and associated documents deleted successfully (Cascade).");
|
||||
setDeletingBodyId(null);
|
||||
await loadBodies();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete standards body.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Doc Actions
|
||||
const handleOpenDocCreate = (bodyId: number) => {
|
||||
setTargetBodyId(bodyId);
|
||||
setEditingDoc(null);
|
||||
setDocForm({
|
||||
wg: "",
|
||||
project_name: "",
|
||||
title: "",
|
||||
doc_ref: "",
|
||||
status: "IS",
|
||||
published_at: "",
|
||||
});
|
||||
setIsDocModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDocEdit = (d: AdminStandardDocument) => {
|
||||
setTargetBodyId(d.body_id);
|
||||
setEditingDoc(d);
|
||||
setDocForm({
|
||||
wg: d.wg,
|
||||
project_name: d.project_name,
|
||||
title: d.title,
|
||||
doc_ref: d.doc_ref,
|
||||
status: d.status,
|
||||
published_at: d.published_at || "",
|
||||
});
|
||||
setIsDocModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDocSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const token = getClientToken();
|
||||
if (!token || !targetBodyId) return;
|
||||
|
||||
const payload = {
|
||||
wg: docForm.wg.trim(),
|
||||
project_name: docForm.project_name.trim(),
|
||||
title: docForm.title.trim(),
|
||||
doc_ref: docForm.doc_ref.trim(),
|
||||
status: docForm.status,
|
||||
published_at: docForm.published_at.trim() || undefined,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingDoc) {
|
||||
await adminUpdateStandardDocument(token, editingDoc.id, payload);
|
||||
setSuccessMsg(`Document "${payload.doc_ref}" updated.`);
|
||||
} else {
|
||||
await adminCreateStandardDocument(token, targetBodyId, payload);
|
||||
setSuccessMsg(`Document "${payload.doc_ref}" added.`);
|
||||
}
|
||||
setIsDocModalOpen(false);
|
||||
await loadBodies();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save standard document.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteDoc = async () => {
|
||||
if (!deletingDocId) return;
|
||||
const token = getClientToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminDeleteStandardDocument(token, deletingDocId);
|
||||
setSuccessMsg("Standard document deleted successfully.");
|
||||
setDeletingDocId(null);
|
||||
await loadBodies();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to delete document.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminHeader
|
||||
title="Standardization Management"
|
||||
description="Manage international and domestic standardization organizations, working groups, and published specifications."
|
||||
action={
|
||||
<button
|
||||
onClick={handleOpenBodyCreate}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
||||
>
|
||||
+ Add Standards Body
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-ink/70">Loading standardization records...</div>
|
||||
) : bodies.length === 0 ? (
|
||||
<div className="bg-paper border border-line rounded-lg p-12 text-center text-ink/60">
|
||||
No standards bodies recorded. Click "+ Add Standards Body" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{bodies.map((body) => {
|
||||
const allDocs = body.projects?.flatMap((p) => p.documents) || [];
|
||||
|
||||
return (
|
||||
<div key={body.id} className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
{/* Body Header Bar */}
|
||||
<div className="px-6 py-4 bg-ivory border-b border-line flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-bold text-base text-ink">{body.org}</span>
|
||||
<span className="text-xs font-mono text-ink/60">({body.full_name})</span>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-bold uppercase ${
|
||||
body.scope === "international"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{body.scope}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-ink/60 mt-1">
|
||||
Period: {body.period} {body.role && `· Role: ${body.role}`} · {allDocs.length} Total Documents
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenDocCreate(body.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-cobalt text-white font-medium hover:bg-cobalt/90"
|
||||
>
|
||||
+ Add Document
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenBodyEdit(body)}
|
||||
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingBodyId(body.id)}
|
||||
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
||||
>
|
||||
Delete Body
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents Table */}
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs font-semibold text-ink/60 bg-paper">
|
||||
<th className="p-3 w-28">Ref / Status</th>
|
||||
<th className="p-3">Title & WG</th>
|
||||
<th className="p-3">Project Group</th>
|
||||
<th className="p-3 w-24">Date</th>
|
||||
<th className="p-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{allDocs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-xs text-ink/50">
|
||||
No documents added for this standards body.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
allDocs.map((d) => (
|
||||
<tr key={d.id} className="hover:bg-ivory/40 text-xs">
|
||||
<td className="p-3">
|
||||
<div className="font-mono font-semibold text-cobalt">{d.doc_ref}</div>
|
||||
<span className="inline-block mt-0.5 px-1.5 py-0.2 text-[10px] rounded bg-ink/5 text-ink/70 font-bold">
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="font-medium text-ink">{d.title}</div>
|
||||
<div className="text-[11px] text-ink/50 mt-0.5">{d.wg}</div>
|
||||
</td>
|
||||
<td className="p-3 text-ink/70">{d.project_name}</td>
|
||||
<td className="p-3 font-mono text-ink/60">{d.published_at || "—"}</td>
|
||||
<td className="p-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenDocEdit(d)}
|
||||
className="px-2 py-0.5 rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingDocId(d.id)}
|
||||
className="px-2 py-0.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body Modal */}
|
||||
<Modal
|
||||
isOpen={isBodyModalOpen}
|
||||
title={editingBody ? "Edit Standards Body" : "Create Standards Body"}
|
||||
onClose={() => setIsBodyModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleBodySubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Organization Acronym *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bodyForm.org}
|
||||
onChange={(e) => setBodyForm({ ...bodyForm, org: e.target.value })}
|
||||
placeholder="e.g. ISO/IEC"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Scope *</label>
|
||||
<select
|
||||
value={bodyForm.scope}
|
||||
onChange={(e) => setBodyForm({ ...bodyForm, scope: e.target.value as "international" | "domestic" })}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="international">International</option>
|
||||
<option value="domestic">Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Full Organization Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bodyForm.full_name}
|
||||
onChange={(e) => setBodyForm({ ...bodyForm, full_name: e.target.value })}
|
||||
placeholder="e.g. International Organization for Standardization"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Period *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bodyForm.period}
|
||||
onChange={(e) => setBodyForm({ ...bodyForm, period: e.target.value })}
|
||||
placeholder="e.g. 2020 - Present"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Role in Committee</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bodyForm.role}
|
||||
onChange={(e) => setBodyForm({ ...bodyForm, role: e.target.value })}
|
||||
placeholder="e.g. Project Leader"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBodyModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingBody ? "Save Changes" : "Create Body"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Doc Modal */}
|
||||
<Modal
|
||||
isOpen={isDocModalOpen}
|
||||
title={editingDoc ? "Edit Standard Document" : "Add Standard Document"}
|
||||
onClose={() => setIsDocModalOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleDocSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Document Reference *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docForm.doc_ref}
|
||||
onChange={(e) => setDocForm({ ...docForm, doc_ref: e.target.value })}
|
||||
placeholder="e.g. ISO/IEC 63246-1"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Status *</label>
|
||||
<select
|
||||
value={docForm.status}
|
||||
onChange={(e) =>
|
||||
setDocForm({
|
||||
...docForm,
|
||||
status: e.target.value as "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress",
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
{DOC_STATUSES.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{st}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Document Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docForm.title}
|
||||
onChange={(e) => setDocForm({ ...docForm, title: e.target.value })}
|
||||
placeholder="e.g. Part 1: Architecture and functional model"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Working Group (WG) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docForm.wg}
|
||||
onChange={(e) => setDocForm({ ...docForm, wg: e.target.value })}
|
||||
placeholder="e.g. ISO/IEC JTC 1/SC 29/WG 11"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Project Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docForm.project_name}
|
||||
onChange={(e) => setDocForm({ ...docForm, project_name: e.target.value })}
|
||||
placeholder="e.g. CCIS (Camera Communications)"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink mb-1">Published Date (ISO 8601 optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docForm.published_at}
|
||||
onChange={(e) => setDocForm({ ...docForm, published_at: e.target.value })}
|
||||
placeholder="e.g. 2023-08"
|
||||
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDocModalOpen(false)}
|
||||
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : editingDoc ? "Save Changes" : "Add Document"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingBodyId !== null}
|
||||
onClose={() => setDeletingBodyId(null)}
|
||||
onConfirm={handleConfirmDeleteBody}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Standards Body (Cascade Warning)"
|
||||
description="Are you sure you want to delete this standards body? All standard documents under this body will also be permanently deleted (Database Cascade)."
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deletingDocId !== null}
|
||||
onClose={() => setDeletingDocId(null)}
|
||||
onConfirm={handleConfirmDeleteDoc}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Delete Standard Document"
|
||||
description="Are you sure you want to delete this standard document?"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -39,16 +39,16 @@
|
||||
--font-sans: 'Geist', 'Pretendard', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
--ink-rgb: 10, 10, 10;
|
||||
--ink-soft-rgb: 38, 36, 32;
|
||||
--ink-mute-rgb: 107, 101, 91;
|
||||
--ivory-rgb: 245, 241, 232;
|
||||
--paper-rgb: 236, 230, 214;
|
||||
--line-rgb: 216, 209, 192;
|
||||
--vermillion-rgb: 217, 52, 43;
|
||||
--vermillion-dark-rgb: 168, 35, 28;
|
||||
--cobalt-rgb: 27, 58, 138;
|
||||
--cobalt-dark-rgb: 18, 40, 99;
|
||||
--ink-rgb: 10 10 10;
|
||||
--ink-soft-rgb: 38 36 32;
|
||||
--ink-mute-rgb: 107 114 128;
|
||||
--ivory-rgb: 255 255 255;
|
||||
--paper-rgb: 248 249 250;
|
||||
--line-rgb: 229 231 235;
|
||||
--vermillion-rgb: 217 52 43;
|
||||
--vermillion-dark-rgb: 168 35 28;
|
||||
--cobalt-rgb: 78 119 255;
|
||||
--cobalt-dark-rgb: 52 90 210;
|
||||
|
||||
--ink: rgb(var(--ink-rgb));
|
||||
--ink-soft: rgb(var(--ink-soft-rgb));
|
||||
@@ -61,7 +61,7 @@
|
||||
--cobalt: rgb(var(--cobalt-rgb));
|
||||
--cobalt-dark: rgb(var(--cobalt-dark-rgb));
|
||||
|
||||
--grain-color: rgba(10, 10, 10, 0.022);
|
||||
--grain-color: rgba(10, 10, 10, 0.015);
|
||||
|
||||
/* CUSTOMIZATION HOOK — MOTION: global reveal timing */
|
||||
--reveal-duration: 600ms;
|
||||
@@ -72,16 +72,16 @@ html.dark,
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--ink-rgb: 245, 241, 232;
|
||||
--ink-soft-rgb: 216, 209, 192;
|
||||
--ink-mute-rgb: 160, 153, 142;
|
||||
--ivory-rgb: 18, 18, 18;
|
||||
--paper-rgb: 30, 28, 26;
|
||||
--line-rgb: 51, 48, 43;
|
||||
--vermillion-rgb: 235, 75, 66;
|
||||
--vermillion-dark-rgb: 255, 107, 97;
|
||||
--cobalt-rgb: 77, 119, 255;
|
||||
--cobalt-dark-rgb: 112, 148, 255;
|
||||
--ink-rgb: 245 241 232;
|
||||
--ink-soft-rgb: 216 209 192;
|
||||
--ink-mute-rgb: 160 153 142;
|
||||
--ivory-rgb: 18 18 18;
|
||||
--paper-rgb: 30 28 26;
|
||||
--line-rgb: 51 48 43;
|
||||
--vermillion-rgb: 235 75 66;
|
||||
--vermillion-dark-rgb: 255 107 97;
|
||||
--cobalt-rgb: 77 119 255;
|
||||
--cobalt-dark-rgb: 112 148 255;
|
||||
|
||||
--grain-color: rgba(245, 241, 232, 0.03);
|
||||
}
|
||||
@@ -172,6 +172,14 @@ body {
|
||||
.draw-underline:hover {
|
||||
background-size: 100% 2px;
|
||||
}
|
||||
|
||||
/* Mobile menu scroll container for low-height / landscape viewports (F4) */
|
||||
.mobile-menu-panel {
|
||||
max-height: calc(100vh - 6rem); /* Fallback: browsers without dvh support */
|
||||
max-height: calc(100dvh - 6rem); /* Modern: dynamic viewport height */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain; /* Prevent chaining scroll to main page */
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
@@ -217,6 +225,11 @@ body {
|
||||
[class*="animate-"] {
|
||||
animation: none !important;
|
||||
}
|
||||
/* 스마트 스티키: 모션 줄임 사용자에게는 항상 펼친 상태 유지 */
|
||||
header[data-smart-sticky] {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<defs>
|
||||
<linearGradient id="cobaltGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4E77FF"/>
|
||||
<stop offset="100%" stop-color="#2B4CDE"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#cobaltGrad)"/>
|
||||
<!-- Network Agent Nodes & Graph -->
|
||||
<circle cx="16" cy="9" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="9" cy="22" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="23" cy="22" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="16" cy="17" r="2" fill="#FFD666"/>
|
||||
|
||||
<path d="M16 9 L9 22 M16 9 L23 22 M9 22 L23 22 M16 9 L16 17 M9 22 L16 17 M23 22 L16 17"
|
||||
stroke="#FFFFFF" stroke-width="1.2" stroke-opacity="0.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
@@ -34,7 +34,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<html lang="ko" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
{/* No-JS fallback for the .reveal scroll-reveal: without this, every
|
||||
|
||||
@@ -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,61 +1,74 @@
|
||||
import React from "react";
|
||||
import { semesters } from "../../content/lectures";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { getSemesters } from "../../lib/api";
|
||||
import type { Semester } from "../../content/types";
|
||||
|
||||
export default function LecturesPage() {
|
||||
const [semesters, setSemesters] = useState<Semester[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSemesters().then((s) => {
|
||||
if (s) setSemesters(s);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const recentSemesters = semesters.slice(0, 3);
|
||||
const olderSemesters = semesters.slice(3);
|
||||
|
||||
const oldestSem = olderSemesters[olderSemesters.length - 1];
|
||||
const newestOlderSem = olderSemesters[0];
|
||||
|
||||
return (
|
||||
<div className="container-content space-y-12 md:space-y-16 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
{/* Header */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
Academic Courses
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink">
|
||||
강의 이력
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
LECTURES
|
||||
</h1>
|
||||
<p className="text-ink-mute text-base max-w-2xl">
|
||||
2016년 가을학기부터 2026년 봄학기까지 개설된 네트워크 및 소프트웨어 분야 약 20개 학기의 학부/대학원 강의 목록입니다.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Recent 3 Semesters */}
|
||||
<section className="space-y-6 pt-6 md:pt-10 border-t border-line">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<div className="font-mono text-xs text-vermillion uppercase tracking-widest font-bold">
|
||||
Recent Courses ({recentSemesters.length} Semesters)
|
||||
</div>
|
||||
<h2 className="font-serif text-3xl font-normal text-ink mt-1">
|
||||
최근 개설 강의
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Recent Courses
|
||||
</h2>
|
||||
<span className="font-mono text-xs text-ink-mute hidden sm:inline">
|
||||
Latest 3 Semesters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-6">
|
||||
{recentSemesters.map((sem, idx) => (
|
||||
<div
|
||||
<Reveal
|
||||
key={idx}
|
||||
className="bg-paper p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between"
|
||||
variant="up"
|
||||
delay={idx * 80}
|
||||
className="h-full"
|
||||
>
|
||||
<div
|
||||
className="bg-paper p-5 sm:p-6 rounded-lg border border-line space-y-4 flex flex-col justify-between shadow-sm h-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-line pb-2">
|
||||
<span className="font-serif text-xl font-normal text-ink">
|
||||
<div className="flex justify-between items-center border-b border-line pb-2.5">
|
||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||
{sem.term} {sem.year}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-vermillion font-bold">
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-vermillion-dark font-bold px-2 py-0.5 rounded bg-vermillion-dark/10">
|
||||
Latest
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 pt-1">
|
||||
<ul className="space-y-2.5 pt-1">
|
||||
{sem.courses.map((c, cIdx) => (
|
||||
<li key={cIdx} className="space-y-0.5">
|
||||
<div className="font-serif text-base text-ink">
|
||||
<div className="font-serif text-sm sm:text-base text-ink leading-snug">
|
||||
{c.en}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">
|
||||
@@ -66,39 +79,43 @@ export default function LecturesPage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute">
|
||||
Course Count: {sem.courses.length}
|
||||
<div className="pt-3 border-t border-line/60 font-mono text-xs text-ink-mute flex justify-between items-center">
|
||||
<span>Total Offerings</span>
|
||||
<span className="font-semibold text-ink">{sem.courses.length} Courses</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Older Semesters (Native <details>/<summary> progressive disclosure) */}
|
||||
<section className="space-y-6 pt-6 border-t border-line">
|
||||
<details className="group border border-line rounded-lg bg-paper p-6 transition-all duration-200">
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="border border-line rounded-lg bg-paper p-5 sm:p-6 transition-all duration-200">
|
||||
<details className="group">
|
||||
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
||||
<div className="space-y-1">
|
||||
<span className="font-serif text-xl font-normal text-ink">
|
||||
이전 전체 강의 이력 ({olderSemesters.length}개 학기) 열람하기
|
||||
<span className="font-serif text-lg sm:text-xl font-normal text-ink">
|
||||
Browse all semesters
|
||||
</span>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
2016 Fall ~ 2024 Spring 학기별 개설 교과목 이력
|
||||
Course history from {oldestSem ? `${oldestSem.year} ${oldestSem.term}` : "2004 Spring"} to {newestOlderSem ? `${newestOlderSem.year} ${newestOlderSem.term}` : "2024 Fall"} ({olderSemesters.length} Semesters)
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-vermillion font-bold border border-vermillion px-3 py-1.5 rounded group-open:bg-vermillion group-open:text-ivory transition-colors">
|
||||
<span className="group-open:hidden">전체 보기 ▾</span>
|
||||
<span className="hidden group-open:inline">접기 ▴</span>
|
||||
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3.5 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
|
||||
<span className="group-open:hidden">Open ▾</span>
|
||||
<span className="hidden group-open:inline">Close ▴</span>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="mt-6 pt-6 border-t border-line grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||||
{olderSemesters.map((sem, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-ivory p-5 rounded border border-line space-y-3"
|
||||
className="bg-ivory p-5 rounded-lg border border-line space-y-3 shadow-sm hover:border-cobalt-dark/40 transition-colors"
|
||||
>
|
||||
<div className="font-serif text-lg font-normal text-ink border-b border-line pb-1.5 flex justify-between">
|
||||
<div className="font-serif text-base sm:text-lg font-normal text-ink border-b border-line pb-2 flex justify-between items-center">
|
||||
<span>
|
||||
{sem.term} {sem.year}
|
||||
</span>
|
||||
@@ -110,7 +127,7 @@ export default function LecturesPage() {
|
||||
<ul className="space-y-2">
|
||||
{sem.courses.map((c, cIdx) => (
|
||||
<li key={cIdx} className="space-y-0.5">
|
||||
<div className="font-serif text-sm text-ink">
|
||||
<div className="font-serif text-sm text-ink leading-snug">
|
||||
{c.en}
|
||||
</div>
|
||||
<div className="font-mono text-[0.7rem] text-ink-mute">
|
||||
@@ -123,6 +140,8 @@ export default function LecturesPage() {
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
|
||||
// CUSTOMIZATION HOOK: Detailed career & background data for Prof. Seok-Joo Koh
|
||||
const EDUCATION = [
|
||||
"KAIST, Management Science, B. S., 1992. 2.",
|
||||
"KAIST, Management Science, M. S., 1994. 2.",
|
||||
"KAIST, Industrial Engineering, Ph. D., 1998. 8.",
|
||||
];
|
||||
|
||||
const CAREER = [
|
||||
{
|
||||
period: "2004. 3 - Present",
|
||||
role: "Professor",
|
||||
org: (
|
||||
<>
|
||||
School of Computer Science and Engineering (CSE),
|
||||
<br />
|
||||
College of IT Engineering (CITE)
|
||||
</>
|
||||
),
|
||||
inst: "Kyungpook National University (KNU)",
|
||||
},
|
||||
{
|
||||
period: "2015. 12 - Present",
|
||||
role: "Director",
|
||||
org: "Institute of Software Education (SWEDU)",
|
||||
inst: "Kyungpook National University (KNU)",
|
||||
},
|
||||
{
|
||||
period: "2024. 3 - 2026. 2",
|
||||
role: "Dean",
|
||||
org: "College of IT Engineering (CITE)",
|
||||
inst: "Kyungpook National University (KNU)",
|
||||
},
|
||||
{
|
||||
period: "1998. 8 - 2004. 2",
|
||||
role: "Senior Researcher",
|
||||
org: "Protocol Engineering Center (PEC)",
|
||||
inst: "Electronics Telecommunications Research Institute (ETRI)",
|
||||
},
|
||||
];
|
||||
|
||||
const STANDARDIZATION = [
|
||||
"IEC TC100: Multimedia Systems and Equipment (2018 ~ Present): WG11 Convenor, Project Editor in WG12, WG30",
|
||||
"ISO/IEC JTC1 SC41/WG5: Internet-of-Things (IoT) Applications (2020 ~ Present): Project Editor",
|
||||
"ISO/IEC JTC1 SC6/WG7: Reliable/Mobile Multicasting (1999 ~ 2019): Project Editor",
|
||||
"ITU-T SG13: Mobility Management (1999 ~ 2015): Project Editor",
|
||||
"ITU-T SG20: VLC-based IoT (2018 ~ 2020): Project Editor",
|
||||
];
|
||||
|
||||
const MEMBERSHIPS = [
|
||||
"KIPS (Korea Information Processing Society)",
|
||||
"KICS (Korean Institute of Communications and Information Sciences)",
|
||||
"KIISE (Korean Institute of Information Scientists and Engineers)",
|
||||
"IEEE (Institute of Electrical and Electronics Engineers)",
|
||||
];
|
||||
|
||||
export function ProfessorDetailsDialog() {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const handleOpen = () => {
|
||||
dialogRef.current?.showModal();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
||||
if (e.target === dialogRef.current) {
|
||||
dialogRef.current?.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-1 font-mono text-xs font-semibold text-cobalt-dark hover:underline focus:outline-none focus:ring-1 focus:ring-cobalt-dark rounded"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span>Details →</span>
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby="prof-dialog-title"
|
||||
onClick={handleBackdropClick}
|
||||
className="bg-paper text-ink border border-line rounded-lg p-0 max-w-fit w-[92vw] max-h-[85vh] supports-[height:100dvh]:max-h-[85dvh] backdrop:bg-ink/50 backdrop:backdrop-blur-sm open:flex open:flex-col shadow-2xl m-auto"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between border-b border-line p-6 bg-paper sticky top-0 z-10">
|
||||
<div>
|
||||
<span className="font-mono text-xs text-cobalt-dark uppercase tracking-wider font-bold">
|
||||
Curriculum Vitae
|
||||
</span>
|
||||
<h2
|
||||
id="prof-dialog-title"
|
||||
className="font-serif text-2xl font-normal text-ink mt-0.5"
|
||||
>
|
||||
Prof. Seok-Joo Koh
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close dialog"
|
||||
className="p-1.5 rounded border border-line hover:bg-ivory text-ink-mute hover:text-ink transition-colors font-mono text-xs"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Scrollable Content */}
|
||||
<div className="p-6 sm:p-8 space-y-8 overflow-y-auto overscroll-contain text-sm">
|
||||
{/* Education */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-wider text-cobalt-dark border-b border-line pb-1.5">
|
||||
Education
|
||||
</h3>
|
||||
<ul className="space-y-2 text-ink/90 font-sans">
|
||||
{EDUCATION.map((edu, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||
<span>{edu}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Career */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-wider text-cobalt-dark border-b border-line pb-1.5">
|
||||
Career
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{CAREER.map((car, idx) => (
|
||||
<div key={idx} className="border-l-2 border-line pl-3 space-y-0.5">
|
||||
<div className="font-mono text-xs text-ink-mute">{car.period}</div>
|
||||
<div className="font-medium text-ink">
|
||||
{car.role} · <span className="text-ink-mute font-normal">{car.org}</span>
|
||||
</div>
|
||||
<div className="text-xs text-ink-soft">{car.inst}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* International Standardization */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-wider text-cobalt-dark border-b border-line pb-1.5">
|
||||
International Standardization
|
||||
</h3>
|
||||
<ul className="space-y-2.5 text-ink/90 font-sans">
|
||||
{STANDARDIZATION.map((std, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 leading-relaxed">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||
<span>{std}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Membership on Academic Society */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-wider text-cobalt-dark border-b border-line pb-1.5">
|
||||
Membership on Academic Society
|
||||
</h3>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-ink/90 font-sans">
|
||||
{MEMBERSHIPS.map((mem, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||
<span>{mem}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,182 +1,211 @@
|
||||
import React from "react";
|
||||
import { activeMembers, alumni } from "../../content/members";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { getMembers, getAlumni } from "../../lib/api";
|
||||
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
|
||||
import type { Member, Alumnus } from "../../content/types";
|
||||
|
||||
const DEGREE_LABEL: Record<string, string> = {
|
||||
Professor: "Professor",
|
||||
PhD: "Ph. D.",
|
||||
PhDCandidate: "Ph. D. Candidate",
|
||||
PhDStudent: "Ph. D. Student",
|
||||
MSCandidate: "M. S. Candidate",
|
||||
MSStudent: "M. S. Student",
|
||||
MS: "M. S.",
|
||||
};
|
||||
|
||||
export default function MembersPage() {
|
||||
const professor = activeMembers.find((m) => m.degree === "Professor");
|
||||
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) => m.degree === "PhD" || m.degree === "PhDCandidate" || m.degree === "PhDStudent"
|
||||
(m) =>
|
||||
m.degree === "PhD" ||
|
||||
m.degree === "PhDCandidate" ||
|
||||
m.degree === "PhDStudent"
|
||||
);
|
||||
const msStudents = activeMembers.filter(
|
||||
(m) => m.degree === "MSCandidate" || m.degree === "MSStudent"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container-content space-y-12 md:space-y-16 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
{/* Header */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
People & Researchers
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink">
|
||||
연구실 구성원
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
LAB MEMBERS
|
||||
</h1>
|
||||
<p className="text-ink-mute text-base max-w-2xl">
|
||||
지도교수와 재학 연구원 16명, 그리고 IT 산업체 및 학계에서 활약하고 있는 60명의 졸업생 인벤토리입니다.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Professor Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-10 border-t border-line">
|
||||
<div className="font-mono text-xs text-vermillion uppercase tracking-widest font-bold">
|
||||
Faculty
|
||||
</div>
|
||||
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="bg-paper p-8 rounded-lg border border-line grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||
<div className="lg:col-span-8 space-y-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-serif text-3xl font-normal text-ink">
|
||||
{professor?.ko || "고석주"} 교수
|
||||
Prof. Seok-Joo Koh
|
||||
</h2>
|
||||
<span className="font-mono text-sm text-ink-mute">
|
||||
{professor?.en || "Seok-Joo Koh"}, Professor
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink-mute leading-relaxed">
|
||||
경북대학교 컴퓨터학부 교수 · AI 에이전트 네트워크 연구실 (ANL) 지도교수.
|
||||
QUIC/gRPC 통신, A2A/ACP 프로토콜, MCP & SKILL 아키텍처 및 에이전트 오케스트레이션 연구를 총괄하고 있습니다.
|
||||
<div className="text-sm text-ink-mute leading-relaxed space-y-2">
|
||||
<p>
|
||||
He received B.S. and M.S. degrees in Management Science from KAIST, Korea in 1992 and 1994, respectively. He also received Ph.D. degree in Industrial Engineering from KAIST, Korea in 1998. From August 1998 to February 2004, he worked for Protocol Engineering Center in ETRI. Since March 2004, he has been with the school of Computer Science and Engineering in the Kyungpook National University as a professor. He directs the AI Agent Networking Laboratory (ANL), leading research on QUIC/gRPC transport, A2A/ACP protocols, MCP & SKILL architectures, and multi-agent orchestration, and serves as Project Editor in IEC TC100, ISO/IEC JTC1 and ITU-T.
|
||||
</p>
|
||||
<div>
|
||||
<ProfessorDetailsDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-line/60 flex flex-wrap gap-4 text-xs font-mono">
|
||||
<a
|
||||
href="mailto:sjkoh@knu.ac.kr"
|
||||
className="text-vermillion hover:underline"
|
||||
className="text-cobalt-dark hover:underline font-bold"
|
||||
>
|
||||
sjkoh@knu.ac.kr
|
||||
</a>
|
||||
<span className="text-ink-mute">•</span>
|
||||
<a
|
||||
href="https://github.com/sjkoh12"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cobalt hover:underline"
|
||||
>
|
||||
GitHub @sjkoh12 ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-4 bg-ivory p-6 rounded border border-line text-xs space-y-2 font-mono">
|
||||
<div className="text-ink font-bold border-b border-line pb-1">
|
||||
Affiliation & Office
|
||||
</div>
|
||||
<p className="text-ink-mute">Kyungpook National University</p>
|
||||
<p className="text-ink-mute">IT Building #5 Room 527</p>
|
||||
<p className="text-ink-mute">Daegu, Republic of Korea</p>
|
||||
<div className="lg:col-span-4 flex justify-center items-center">
|
||||
<div className="relative w-48 h-48 sm:w-56 sm:h-56 md:w-60 md:h-60 rounded-xl overflow-hidden border border-line bg-transparent">
|
||||
<Image
|
||||
src="/images/prof_profile.png"
|
||||
alt="Prof. Seok-Joo Koh"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 224px, 240px"
|
||||
className="object-cover object-top"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Active Researchers Section */}
|
||||
<section className="space-y-8 pt-6 md:pt-10 border-t border-line">
|
||||
<section className="space-y-8 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<div className="font-mono text-xs text-cobalt uppercase tracking-widest font-bold">
|
||||
Active Researchers ({activeMembers.length} Members)
|
||||
</div>
|
||||
<h2 className="font-serif text-3xl font-normal text-ink mt-1">
|
||||
재학 연구원
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Active Researchers
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Ph.D. Candidates / Students */}
|
||||
<div className="space-y-4">
|
||||
<Reveal variant="up">
|
||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||
Ph.D. Course ({phdStudents.length})
|
||||
</h3>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{phdStudents.map((mem, idx) => (
|
||||
<div
|
||||
<Reveal
|
||||
key={idx}
|
||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="h-full"
|
||||
>
|
||||
<div
|
||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-serif text-lg font-normal text-ink">
|
||||
{mem.ko}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-cobalt">
|
||||
{mem.degree}
|
||||
<span className="font-mono text-xs text-cobalt-dark">
|
||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||
{mem.affiliation && (
|
||||
<div className="text-xs text-vermillion pt-1 border-t border-line/40">
|
||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||
with {mem.affiliation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M.S. Candidates / Students */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<Reveal variant="up">
|
||||
<h3 className="font-mono text-xs uppercase tracking-wider text-ink-mute border-b border-line pb-2">
|
||||
M.S. Course ({msStudents.length})
|
||||
</h3>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{msStudents.map((mem, idx) => (
|
||||
<div
|
||||
<Reveal
|
||||
key={idx}
|
||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="h-full"
|
||||
>
|
||||
<div
|
||||
className="bg-paper p-5 rounded border border-line space-y-2 hover:border-cobalt transition-colors h-full"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-serif text-lg font-normal text-ink">
|
||||
{mem.ko}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-cobalt">
|
||||
{mem.degree}
|
||||
<span className="font-mono text-xs text-cobalt-dark">
|
||||
{DEGREE_LABEL[mem.degree] || mem.degree}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">{mem.en}</div>
|
||||
{mem.affiliation && (
|
||||
<div className="text-xs text-vermillion pt-1 border-t border-line/40">
|
||||
<div className="text-xs text-vermillion-dark pt-1 border-t border-line/40">
|
||||
with {mem.affiliation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Alumni Section (Native <details>/<summary> progressive disclosure) */}
|
||||
<section className="space-y-6 pt-6 md:pt-10 border-t border-line">
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-widest font-bold">
|
||||
Alumni Inventory ({alumni.length} Graduates)
|
||||
</div>
|
||||
<h2 className="font-serif text-3xl font-normal text-ink mt-1">
|
||||
졸업생 목록
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Graduates
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<details className="group border border-line rounded-lg bg-paper p-6 transition-all duration-200">
|
||||
<summary className="flex justify-between items-center cursor-pointer list-none select-none">
|
||||
<div className="space-y-1">
|
||||
<span className="font-serif text-xl font-normal text-ink">
|
||||
전체 졸업생 {alumni.length}명 열람하기
|
||||
Browse all graduates
|
||||
</span>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
2007년부터 2026년까지 수여된 박사(Ph.D.) 및 석사(M.S.) 졸업생 이력
|
||||
Ph. D. and M. S. graduates conferred from 2007 to 2026
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-vermillion font-bold border border-vermillion px-3 py-1.5 rounded group-open:bg-vermillion group-open:text-ivory transition-colors">
|
||||
<span className="group-open:hidden">전체 보기 ▾</span>
|
||||
<span className="hidden group-open:inline">접기 ▴</span>
|
||||
<span className="font-mono text-xs text-vermillion-dark font-bold border border-vermillion-dark px-3 py-1.5 rounded group-open:bg-vermillion-dark group-open:text-white dark:group-open:text-ivory transition-colors">
|
||||
<span className="group-open:hidden">Open ▾</span>
|
||||
<span className="hidden group-open:inline">Close ▴</span>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
@@ -191,7 +220,7 @@ export default function MembersPage() {
|
||||
{alum.ko} ({alum.en})
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-mute">
|
||||
{alum.degree}
|
||||
{DEGREE_LABEL[alum.degree] || alum.degree}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute flex justify-between">
|
||||
@@ -199,7 +228,7 @@ export default function MembersPage() {
|
||||
{alum.major && <span>{alum.major}</span>}
|
||||
</div>
|
||||
{alum.currentPosition && (
|
||||
<div className="text-xs text-cobalt pt-1 border-t border-line/40">
|
||||
<div className="text-xs text-cobalt-dark pt-1 border-t border-line/40">
|
||||
{alum.currentPosition}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function NotFound() {
|
||||
return (
|
||||
<div className="container-content py-20 sm:py-32 flex flex-col items-center justify-center text-center min-h-[60vh]">
|
||||
<div className="border border-line bg-paper p-8 sm:p-12 rounded-lg max-w-xl w-full space-y-6 shadow-sm">
|
||||
<div className="inline-block px-3 py-1 bg-vermillion/10 text-vermillion font-mono text-xs font-bold rounded tracking-widest uppercase">
|
||||
<div className="inline-block px-3 py-1 bg-vermillion/10 text-vermillion-dark font-mono text-xs font-bold rounded tracking-widest uppercase">
|
||||
404 Error
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl sm:text-4xl font-normal text-ink leading-tight">
|
||||
@@ -19,7 +19,7 @@ export default function NotFound() {
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-ink bg-ink text-ivory hover:bg-vermillion hover:border-vermillion transition-colors duration-200 text-sm font-medium rounded-md"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-ink bg-ink text-ivory hover:bg-vermillion-dark hover:border-vermillion-dark transition-colors duration-200 text-sm font-medium rounded-md"
|
||||
>
|
||||
메인 페이지로 돌아가기
|
||||
</Link>
|
||||
|
||||
+148
-133
@@ -1,203 +1,218 @@
|
||||
import React from "react";
|
||||
import HeroComposition from "./_components/HeroComposition";
|
||||
import { DirectionsBlock } from "./_components/DirectionsBlock";
|
||||
import { ProjectPageView } from "./_components/ProjectPageView";
|
||||
import { publications, patents } from "../content/publications";
|
||||
"use client";
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
no: "01",
|
||||
en: "Extensibility & Protocol",
|
||||
ko: "AI Agent 기능 확장 프로토콜",
|
||||
theme: "vermillion" as const,
|
||||
topics: [
|
||||
"Model Context Protocol (MCP) 서버/클라이언트 아키텍처",
|
||||
"SKILL 도구 캡슐화 및 재사용 패키징",
|
||||
"CLI 에이전트 인터페이스 제어 및 웹 가동 제어",
|
||||
],
|
||||
desc: "에이전트가 외부 도구 및 시스템과 표준화된 방식으로 연동할 수 있도록 MCP와 SKILL 패키징 기술을 개발합니다.",
|
||||
},
|
||||
{
|
||||
no: "02",
|
||||
en: "Multi-Agent Systems",
|
||||
ko: "멀티 에이전트 협업 및 오케스트레이션",
|
||||
theme: "cobalt" as const,
|
||||
topics: [
|
||||
"HERDR 백그라운드 프로세스 컨테이너 격리 제어",
|
||||
"crewAI 기반 역할 분담 및 태스크 위임 체계",
|
||||
"LangGraph 순환 상태 그래프 기반 워크플로우 제어",
|
||||
],
|
||||
desc: "다중 에이전트 환경에서 독립적인 역할 분담과 선순환 오케스트레이션을 보장하는 자율 협업 플랫폼을 연구합니다.",
|
||||
},
|
||||
{
|
||||
no: "03",
|
||||
en: "Interoperability Standards",
|
||||
ko: "에이전트 상호운용성 개방형 표준",
|
||||
theme: "vermillion" as const,
|
||||
topics: [
|
||||
"Agent-to-Agent (A2A) 프로토콜 규격 정의 및 검증",
|
||||
"Agent Communication Protocol (ACP) 메시지 교환 규격",
|
||||
"Agent Card (/.well-known/agent-card.json) 메타데이터 검증",
|
||||
],
|
||||
desc: "서로 다른 프레임워크 기반의 에이전트 간의 상호운용성을 보장하는 국제 표준 개방형 통신 프로토콜을 구현합니다.",
|
||||
},
|
||||
{
|
||||
no: "04",
|
||||
en: "High-Performance Backbone",
|
||||
ko: "AIoT & 고성능 통신 백본",
|
||||
theme: "cobalt" as const,
|
||||
topics: [
|
||||
"gRPC / QUIC 양방향 스트리밍 에이전트 백본 통신",
|
||||
"Protocol Buffers (Protobuf) 직렬화 통신 최적화",
|
||||
"MQTT / CoAP 엣지 메시 통신망 통합",
|
||||
],
|
||||
desc: "초저지연 양방향 스트리밍 통신 인프라를 바탕으로 고성능 에이전트 백본 네트워크를 설계하고 실증합니다.",
|
||||
},
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import Counter from "@/components/Counter";
|
||||
import HeroComposition from "./_components/HeroComposition";
|
||||
import { ProjectPageView } from "./_components/ProjectPageView";
|
||||
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
|
||||
import type { ResearchProject } from "../content/types";
|
||||
|
||||
const INTERNATIONAL_SDOS = [
|
||||
"IEC TC100: Multimedia Systems and Equipment",
|
||||
"ISO/IEC JTC1/SC41: Internet-of-Things (IoT) Applications",
|
||||
"ITU-T SG20: IoT Services",
|
||||
"ITU-T SG13: Mobility Management",
|
||||
"ISO/IEC JTC1/SC6: Reliable Multicasting",
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const pubCount = publications.length + patents.length;
|
||||
const [stats, setStats] = useState({
|
||||
intlPubsCount: 0,
|
||||
patentCount: 0,
|
||||
stdDocsCount: 0,
|
||||
});
|
||||
const [researchAreas, setResearchAreas] = useState<string[]>([]);
|
||||
const [researchProjects, setResearchProjects] = useState<ResearchProject[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getStatsSummary().then((s) => {
|
||||
if (s) setStats(s);
|
||||
});
|
||||
getResearchAreaNames().then((areas) => {
|
||||
if (areas) setResearchAreas(areas);
|
||||
});
|
||||
getResearchProjects().then((projs) => {
|
||||
if (projs) setResearchProjects(projs);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
{/* Hero Section */}
|
||||
<section className="relative">
|
||||
<Reveal variant="up">
|
||||
<div className="mb-4">
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Kyungpook National University · AI Agent Networking Lab (ANL)
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:items-center">
|
||||
<div className="lg:col-span-7 space-y-6 lg:pt-4">
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl font-normal text-ink leading-tight">
|
||||
Multi-Agent <br />
|
||||
<span className="italic font-serif text-vermillion">Orchestration</span>
|
||||
<span className="italic font-serif text-cobalt-dark">Orchestration</span>
|
||||
</h1>
|
||||
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl">
|
||||
AI 에이전트 네트워크 연구실(ANL)은 멀티 AI 에이전트 오케스트레이션을 위해 서로 소통하고 협력하는 에이전트 네트워킹 기술과 표준 프레임워크 기술을 연구합니다.
|
||||
<p className="text-ink-mute text-base sm:text-lg leading-relaxed max-w-2xl mt-4">
|
||||
The AI Agent Networking Laboratory (ANL) studies agent networking technologies and standard frameworks that enable AI agents to communicate and collaborate with one another for multi-agent orchestration.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Metrics Row (Derived from data array, AC-13) */}
|
||||
{/* Metrics Row (Derived from real content data) */}
|
||||
<div className="pt-6 border-t border-line grid grid-cols-3 gap-4">
|
||||
<Reveal variant="up" delay={80}>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-vermillion font-normal">
|
||||
{PILLARS.length}
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
<Counter value={stats.intlPubsCount} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1">
|
||||
Research Areas
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
International Journals & Conference
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal variant="up" delay={160}>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-cobalt font-normal">
|
||||
220+
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
<Counter value={stats.stdDocsCount} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1">
|
||||
Publications
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
Standardization Contributions
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal variant="up" delay={240}>
|
||||
<div>
|
||||
<div className="font-serif text-3xl text-ink font-normal">
|
||||
2026
|
||||
<div className="font-serif text-3xl text-cobalt-dark font-normal">
|
||||
<Counter value={stats.patentCount} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1">
|
||||
Issue Static
|
||||
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
|
||||
Patent<br />
|
||||
<span className="text-[0.68rem] tracking-normal font-sans normal-case text-ink-mute">
|
||||
(IoT Architecture & Protocols)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 border border-line bg-paper p-4 rounded-lg">
|
||||
<Reveal variant="scale" delay={120} className="w-full max-w-[500px] mx-auto my-[30px] lg:my-0 lg:max-w-none lg:mx-0 lg:col-span-5 border border-line bg-paper p-4 rounded-lg">
|
||||
<HeroComposition />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Research Project PageView Component */}
|
||||
<ProjectPageView />
|
||||
<ProjectPageView projects={researchProjects} />
|
||||
|
||||
{/* Core Research Areas Grid */}
|
||||
<section className="space-y-8 pt-20 md:pt-28 border-t border-line">
|
||||
<div className="flex justify-between items-end">
|
||||
{/* 14 Research Areas Grid */}
|
||||
<section className="space-y-8 pt-16 md:pt-20 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="font-mono text-xs text-vermillion uppercase tracking-widest font-bold">
|
||||
Research Areas
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Core Domains
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-3xl font-normal text-ink mt-1">
|
||||
핵심 연구 분야
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
Research Areas
|
||||
</h2>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-mute">
|
||||
Magazine Layout 01–04
|
||||
</div>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
{researchAreas.length} fundamental networking protocols and AI system domains
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{PILLARS.map((pillar) => {
|
||||
const isVermillion = pillar.theme === "vermillion";
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{researchAreas.map((area, idx) => {
|
||||
const no = String(idx + 1).padStart(2, "0");
|
||||
const isOdd = (idx + 1) % 2 !== 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pillar.no}
|
||||
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200"
|
||||
<Reveal
|
||||
key={no}
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="bg-paper p-6 rounded-lg border border-line flex flex-col justify-between hover:border-ink transition-colors duration-200 h-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-sm font-bold ${
|
||||
isVermillion ? "text-vermillion" : "text-cobalt"
|
||||
className={`font-mono text-xs font-bold ${
|
||||
isOdd ? "text-vermillion-dark" : "text-cobalt-dark"
|
||||
}`}
|
||||
>
|
||||
[{pillar.no}]
|
||||
{no}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
{pillar.en}
|
||||
<span className="font-mono text-[0.68rem] text-ink-mute uppercase tracking-wider">
|
||||
Topic #{no}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-serif text-xl font-normal text-ink">
|
||||
{pillar.ko}
|
||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||
{area}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-ink-mute leading-relaxed">
|
||||
{pillar.desc}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2 pt-2">
|
||||
{pillar.topics.map((topic, tIdx) => (
|
||||
<li
|
||||
key={tIdx}
|
||||
className="text-xs text-ink flex items-start gap-2"
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full mt-1 ${
|
||||
isVermillion ? "bg-vermillion" : "bg-cobalt"
|
||||
}`}
|
||||
></span>
|
||||
<span>{topic}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-line/60 flex justify-between items-center text-xs font-mono text-ink-mute">
|
||||
<span>ANL Core Spec</span>
|
||||
<span
|
||||
className={
|
||||
isVermillion ? "text-vermillion" : "text-cobalt"
|
||||
}
|
||||
>
|
||||
Pillar #{pillar.no}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Directions Block */}
|
||||
<DirectionsBlock />
|
||||
{/* International Standardizations Section */}
|
||||
<section className="space-y-6 pt-16 md:pt-20 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-cobalt-dark"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-cobalt-dark font-bold">
|
||||
Global Engagement
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
International Standardizations
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal variant="up">
|
||||
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6">
|
||||
<p className="text-ink/80 text-sm sm:text-base leading-relaxed max-w-3xl">
|
||||
Along with the research activities, we are also in pursuit of the International Standardization, from the market deployment perspective, in the following SDOs (Standard-Defining Organizations):
|
||||
</p>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2">
|
||||
{INTERNATIONAL_SDOS.map((sdo, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 text-sm text-ink font-sans">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-cobalt-dark mt-2 shrink-0"></span>
|
||||
<span>{sdo}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="pt-4 border-t border-line/60">
|
||||
<Link
|
||||
href="/standardization"
|
||||
className="inline-flex items-center gap-2 font-mono text-xs font-semibold uppercase tracking-wider text-cobalt-dark hover:text-ink transition-colors draw-underline"
|
||||
>
|
||||
<span>Please refer to here for details →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { CategoryNav } from "../../_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../../../lib/api";
|
||||
import { PUB_CATEGORIES } from "../../../../content/categories";
|
||||
import type { PubCategory, Publication, Patent } from "../../../../content/types";
|
||||
|
||||
export function PublicationCategoryClient({ category }: { category: string }) {
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||
if (!currentCat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = category as PubCategory;
|
||||
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [patents, setPatents] = useState<Patent[]>([]);
|
||||
|
||||
const [selectedYear, setSelectedYear] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
getPublications().then((p) => {
|
||||
if (p) setPublications(p);
|
||||
});
|
||||
getPatents().then((pat) => {
|
||||
if (pat) setPatents(pat);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
|
||||
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
|
||||
patent: patents.length,
|
||||
};
|
||||
|
||||
let allRecords: any[] = [];
|
||||
if (slug === "patent") {
|
||||
allRecords = patents.map((p) => ({
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
appNo: p.applicationNo,
|
||||
appDate: p.applicationAt,
|
||||
regNo: p.registrationNo,
|
||||
regDate: p.registrationAt,
|
||||
country: p.country,
|
||||
publishedAt: p.publishedAt,
|
||||
isPatent: true,
|
||||
}));
|
||||
} else {
|
||||
allRecords = publications
|
||||
.filter((p) => p.category === slug)
|
||||
.map((p) => ({
|
||||
title: p.title,
|
||||
venue: p.venue,
|
||||
volume: p.volume,
|
||||
publishedAt: p.publishedAt,
|
||||
kci: p.kci,
|
||||
isPatent: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract distinct available years from date strings (YYYY-MM or YYYY)
|
||||
const availableYears = Array.from(
|
||||
new Set(
|
||||
allRecords
|
||||
.map((r) => {
|
||||
const dateStr = r.publishedAt || "";
|
||||
const match = dateStr.match(/\b(19\d\d|20\d\d)\b/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// Filter records by selected year
|
||||
const records = allRecords.filter((r) => {
|
||||
if (selectedYear === "all") return true;
|
||||
const dateStr = r.publishedAt || "";
|
||||
const yr = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||
return yr === selectedYear;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section className="space-y-1">
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute mt-1">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Category Nav Cards */}
|
||||
<section className="pt-6 md:pt-8 border-t border-line">
|
||||
<CategoryNav currentCategory={slug} counts={counts} />
|
||||
</section>
|
||||
|
||||
{/* Record List Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
|
||||
>
|
||||
<option value="all">All Years ({allRecords.length})</option>
|
||||
{availableYears.map((yr) => {
|
||||
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({yrCount})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{selectedYear !== "all" && (
|
||||
<button
|
||||
onClick={() => setSelectedYear("all")}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
Showing {records.length} of {allRecords.length} Records
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||
<p className="font-serif text-lg text-ink">
|
||||
No records found in this category.
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
Records will be updated periodically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{records.map((rec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||
{rec.title}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={rec.publishedAt}
|
||||
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
|
||||
>
|
||||
{rec.publishedAt}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{rec.isPatent ? (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink">Inventors: {rec.inventors}</p>
|
||||
<p>
|
||||
App No: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||
</p>
|
||||
{rec.regNo && (
|
||||
<p className="text-cobalt-dark font-bold">
|
||||
Reg No: {rec.regNo} ({rec.regDate})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-xs text-ink-mute font-mono">
|
||||
<p className="text-ink font-serif italic">{rec.venue}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{rec.volume}</span>
|
||||
{rec.kci && (
|
||||
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||
KCI Indexed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoryNav } from "../_components/CategoryNav";
|
||||
import { publications, patents } from "../../../content/publications";
|
||||
import type { Metadata } from "next";
|
||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||
import { PubCategory } from "../../../content/types";
|
||||
import { PublicationCategoryClient } from "./_components/PublicationCategoryClient";
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -11,139 +8,21 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
// SSG static pre-rendering (● SSG) for Next.js 14
|
||||
export function generateStaticParams() {
|
||||
return PUB_CATEGORIES.map((cat) => ({
|
||||
category: cat.slug,
|
||||
}));
|
||||
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) {
|
||||
return { title: "Publications" };
|
||||
}
|
||||
return {
|
||||
title: `${currentCat.label} | Publications`,
|
||||
description: `AI Agent Networking Lab (ANL) ${currentCat.label} research inventory and records`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PublicationCategoryPage({ params }: PageProps) {
|
||||
const { category } = params;
|
||||
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
|
||||
|
||||
if (!currentCat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = category as PubCategory;
|
||||
|
||||
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 space-y-8 md:space-y-12 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
{/* Header */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
Publications Category
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
<p className="text-ink-mute text-base max-w-2xl">
|
||||
{currentCat.desc} — 총 {records.length}건의 실데이터 카탈로그입니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Category Nav */}
|
||||
<CategoryNav currentCategory={slug} />
|
||||
|
||||
{/* Record List Section */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex justify-between items-center border-b border-line pb-3">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
Showing {records.length} Pre-rendered Records
|
||||
</span>
|
||||
<span className="font-mono text-xs text-vermillion font-bold">
|
||||
SSG ● Static HTML
|
||||
</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">
|
||||
현재 등록된 실적이 0건입니다.
|
||||
</p>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
(D-01 카테고리 정류 확정 후 후속 배치가 수행됩니다)
|
||||
</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 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">발명자: {rec.inventors}</p>
|
||||
<p>
|
||||
출원번호: {rec.appNo} ({rec.country}, {rec.appDate})
|
||||
</p>
|
||||
{rec.regNo && (
|
||||
<p className="text-cobalt font-bold">
|
||||
등록번호: {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 text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
|
||||
KCI 등재
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
return <PublicationCategoryClient category={params.category} />;
|
||||
}
|
||||
@@ -1,31 +1,120 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import Counter from "@/components/Counter";
|
||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||
import { PubCategory } from "../../../content/types";
|
||||
|
||||
interface CategoryNavProps {
|
||||
currentCategory?: PubCategory;
|
||||
totalCount?: number;
|
||||
export interface CategoryCounts {
|
||||
"intl-journal-conf"?: number;
|
||||
"domestic-journal-conf"?: number;
|
||||
patent?: number;
|
||||
}
|
||||
|
||||
export function CategoryNav({ currentCategory }: CategoryNavProps) {
|
||||
interface CategoryNavProps {
|
||||
currentCategory?: PubCategory;
|
||||
counts?: CategoryCounts;
|
||||
}
|
||||
|
||||
export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
||||
const getCount = (slug: string) => {
|
||||
if (counts?.[slug as keyof CategoryCounts] !== undefined) {
|
||||
return counts[slug as keyof CategoryCounts]!;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-line pb-4">
|
||||
{PUB_CATEGORIES.map((cat) => {
|
||||
const isActive = currentCategory === cat.slug;
|
||||
return (
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Categories
|
||||
</h2>
|
||||
{currentCategory && (
|
||||
<Link
|
||||
href="/publications"
|
||||
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
||||
>
|
||||
← Overview
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-5 sm:gap-6 md:grid-cols-3">
|
||||
{PUB_CATEGORIES.map((cat, idx) => {
|
||||
const count = getCount(cat.slug);
|
||||
const isActive = currentCategory === cat.slug;
|
||||
|
||||
return (
|
||||
<Reveal
|
||||
key={cat.slug}
|
||||
href={`/publications/${cat.slug}`}
|
||||
className={`px-4 py-2 rounded text-xs font-mono tracking-wider transition-colors ${
|
||||
variant="up"
|
||||
delay={idx * 80}
|
||||
className="h-full"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all h-full ${
|
||||
isActive
|
||||
? "bg-vermillion text-ivory font-bold"
|
||||
: "bg-paper text-ink border border-line hover:border-vermillion"
|
||||
? "border-2 border-cobalt-dark shadow-md"
|
||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
||||
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
||||
}`}
|
||||
>
|
||||
Category
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={`font-serif text-xl ${
|
||||
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{cat.subtitle && (
|
||||
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
||||
{cat.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
||||
<span className="font-display text-3xl font-bold text-ink">
|
||||
<Counter value={count} />
|
||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
||||
Active View ●
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/publications/${cat.slug}`}
|
||||
aria-label={`View all ${cat.label} records`}
|
||||
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Publications",
|
||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||
};
|
||||
|
||||
export default function PublicationsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,5 +1,96 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { CategoryNav } from "./_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../lib/api";
|
||||
import type { Publication, Patent } from "../../content/types";
|
||||
|
||||
export default function PublicationsIndexPage() {
|
||||
redirect("/publications/intl-journal-conf");
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [patents, setPatents] = useState<Patent[]>([]);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Selected representative publications (curated via database isHighlight flag)
|
||||
const highlights = publications.filter((p) => p.isHighlight);
|
||||
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
PUBLICATIONS
|
||||
</h1>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Category Summary Cards */}
|
||||
<section className="pt-6 md:pt-8 border-t border-line">
|
||||
<CategoryNav counts={counts} />
|
||||
</section>
|
||||
|
||||
{/* Highlights Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<Reveal variant="up">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Highlights
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm">
|
||||
{highlights.map((pub, idx) => (
|
||||
<Reveal
|
||||
key={`${pub.category}-${idx}`}
|
||||
as="article"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="p-5 hover:bg-ivory/60 transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-mono text-ink-mute">
|
||||
<span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold">
|
||||
{pub.publishedAt?.substring(0, 7) || "Recent"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="capitalize">{pub.category.replace(/-/g, " ")}</span>
|
||||
{pub.doi && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-vermillion-dark">DOI: {pub.doi}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-serif text-base font-normal text-ink leading-snug">
|
||||
{pub.title}
|
||||
</h3>
|
||||
{pub.authors && (
|
||||
<p className="text-xs text-ink-mute">
|
||||
{pub.authors}
|
||||
</p>
|
||||
)}
|
||||
{pub.venue && (
|
||||
<p className="text-xs italic text-ink-mute">
|
||||
{pub.venue}{pub.volume ? `, ${pub.volume}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,43 +1,46 @@
|
||||
import React from "react";
|
||||
import { standardsBodies } from "../../content/standards";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { getStandardsBodies } from "../../lib/api";
|
||||
import type { StandardsBody } from "../../content/types";
|
||||
|
||||
export default function StandardizationPage() {
|
||||
const totalDocs = standardsBodies.reduce(
|
||||
(acc, body) =>
|
||||
acc + body.projects.reduce((pAcc, proj) => pAcc + proj.documents.length, 0),
|
||||
0
|
||||
);
|
||||
const [standardsBodies, setStandardsBodies] = useState<StandardsBody[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getStandardsBodies().then((sb) => {
|
||||
if (sb) setStandardsBodies(sb);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div className="container-content space-y-12 md:space-y-16 pt-10 md:pt-16 pb-16 md:pb-24">
|
||||
{/* Header */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
Open Standards & Contributions
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink">
|
||||
표준화 연구 및 기여
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
International Standardization
|
||||
</h1>
|
||||
<p className="text-ink-mute text-base max-w-2xl">
|
||||
IEC TC100, ISO/IEC JTC1, ITU-T, TTA 등 국제/국내 표준화 기구를 통해 제정 및 진행 중인 {standardsBodies.length}개 기구 {totalDocs}건의 표준 규격 문서 계층 인벤토리입니다.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Standards Bodies Section */}
|
||||
<section className="space-y-12 pt-6 md:pt-10 border-t border-line">
|
||||
<section className="space-y-8 sm:space-y-10 pt-6 md:pt-8 border-t border-line">
|
||||
{standardsBodies.map((body, idx) => (
|
||||
<div key={idx} className="bg-paper p-8 rounded-lg border border-line space-y-6">
|
||||
<Reveal
|
||||
key={idx}
|
||||
variant="up"
|
||||
delay={idx * 120}
|
||||
>
|
||||
<div className="bg-paper p-6 sm:p-8 rounded-lg border border-line space-y-6 shadow-sm">
|
||||
<div className="flex flex-wrap justify-between items-start gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-vermillion font-bold uppercase">
|
||||
<span className="font-mono text-xs text-cobalt-dark font-bold uppercase">
|
||||
[{body.scope}]
|
||||
</span>
|
||||
{body.role && (
|
||||
<span className="bg-vermillion text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
|
||||
<span className="bg-vermillion-dark text-ivory font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold">
|
||||
{body.role}
|
||||
</span>
|
||||
)}
|
||||
@@ -61,7 +64,7 @@ export default function StandardizationPage() {
|
||||
<div key={pIdx} className="bg-ivory p-6 rounded border border-line space-y-3">
|
||||
<div className="font-serif text-lg font-normal text-ink border-b border-line/60 pb-2 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs text-cobalt font-bold mr-2">
|
||||
<span className="font-mono text-xs text-cobalt-dark font-bold mr-2">
|
||||
{proj.wg}
|
||||
</span>
|
||||
<span>{proj.name}</span>
|
||||
@@ -85,8 +88,8 @@ export default function StandardizationPage() {
|
||||
<span
|
||||
className={`font-mono text-[0.65rem] px-2 py-0.5 rounded font-bold shrink-0 ${
|
||||
doc.status === "InProgress"
|
||||
? "bg-vermillion text-ivory"
|
||||
: "bg-cobalt text-ivory"
|
||||
? "bg-vermillion-dark text-ivory"
|
||||
: "bg-cobalt-dark text-ivory"
|
||||
}`}
|
||||
>
|
||||
{doc.status}
|
||||
@@ -103,13 +106,14 @@ export default function StandardizationPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-ink-mute font-mono italic">
|
||||
진행 중인 주 프로젝트 규격서
|
||||
Project specification in progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,78 +1,168 @@
|
||||
"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.
|
||||
const links = [
|
||||
{ href: "/", ko: "홈", en: "Home" },
|
||||
{ href: "/members", ko: "구성원", en: "Members" },
|
||||
{ href: "/publications", ko: "논문", en: "Publications" },
|
||||
{ href: "/lectures", ko: "강의", en: "Lectures" },
|
||||
{ href: "/standardization", ko: "표준화", en: "Standardization" },
|
||||
{ href: "/", en: "Home" },
|
||||
{ href: "/members", en: "Members" },
|
||||
{ href: "/publications", en: "Publications" },
|
||||
{ href: "/lectures", en: "Lectures" },
|
||||
{ href: "/standardization", en: "Standardization" },
|
||||
];
|
||||
|
||||
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 */}
|
||||
<Marquee
|
||||
items={[
|
||||
"AI AGENT NETWORKING LAB",
|
||||
"AI AGENT NETWORKING",
|
||||
"A2A PROTOCOLS & ACP",
|
||||
"QUIC MULTI-AGENT ORCHESTRATION",
|
||||
"KNU / CSE / ANL",
|
||||
"KYUNGPOOK NATIONAL UNIVERSITY",
|
||||
"QUIC BASED INTERFACE MODULE",
|
||||
"MULTI-AGENT ORCHESTRATION",
|
||||
]}
|
||||
reverse
|
||||
className="border-b border-line py-4 font-display text-lg tracking-wide text-ink/90"
|
||||
/>
|
||||
|
||||
<div className="container-content grid gap-12 py-16 lg:grid-cols-12">
|
||||
{/* Masthead title */}
|
||||
<div className="lg:col-span-5">
|
||||
<p className="kicker text-ink-mute">Colophon</p>
|
||||
<h3 className="headline-ko mt-3 text-2xl leading-snug text-ink">
|
||||
AI 에이전트 네트워크 연구실
|
||||
</h3>
|
||||
<p className="font-display mt-1 text-xl italic text-ink-soft">
|
||||
AI Agent Networking Lab (ANL)
|
||||
</p>
|
||||
<p className="mt-5 max-w-sm text-sm leading-relaxed text-ink-mute">
|
||||
경북대학교 컴퓨터학부<br />
|
||||
Kyungpook National University,<br />
|
||||
School of Computer Science and Engineering
|
||||
<div className="container-content grid gap-10 py-16 lg:grid-cols-12">
|
||||
{/* Brand Block */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="group inline-flex items-center gap-3.5 shrink-0"
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-10 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">
|
||||
<svg viewBox="0 0 32 32" className="w-full h-full" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="footerLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#4E77FF" />
|
||||
<stop offset="100%" stopColor="#2B4CDE" />
|
||||
</linearGradient>
|
||||
<radialGradient id="footerNodeGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#FFD666" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#FF9C1A" stopOpacity="0.8" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#footerLogoGrad)" />
|
||||
{/* Network Graph Edges */}
|
||||
<path
|
||||
d="M16 9 L9 22 M16 9 L23 22 M9 22 L23 22 M16 9 L16 17 M9 22 L16 17 M23 22 L16 17"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="1.2"
|
||||
strokeOpacity="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Network Agent Outer Nodes */}
|
||||
<circle cx="16" cy="9" r="2.5" fill="#FFFFFF" />
|
||||
<circle cx="9" cy="22" r="2.5" fill="#FFFFFF" />
|
||||
<circle cx="23" cy="22" r="2.5" fill="#FFFFFF" />
|
||||
{/* Central Coordinating Hub Node */}
|
||||
<circle cx="16" cy="17" r="2.2" fill="url(#footerNodeGlow)" className="animate-pulse" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="headline-ko text-xl font-bold tracking-tight text-ink group-hover:text-cobalt-dark transition-colors">
|
||||
ANL
|
||||
</span>
|
||||
<span className="kicker mt-1 transition-colors group-hover:text-cobalt-dark">
|
||||
AI Agent Networking LAB
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<p className="text-xs sm:text-sm text-ink-mute leading-relaxed max-w-sm">
|
||||
Research on agent networking technologies and standard framework technologies for multi-AI agent orchestration.
|
||||
</p>
|
||||
|
||||
<div className="pt-2 flex flex-wrap gap-2 text-xs font-mono">
|
||||
<a
|
||||
href="https://www.knu.ac.kr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-ivory border border-line text-ink-soft hover:text-cobalt-dark hover:border-cobalt/40 transition-colors"
|
||||
>
|
||||
<span>KNU</span>
|
||||
<span className="text-[0.65rem] text-ink-mute">↗</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://cse.knu.ac.kr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-ivory border border-line text-ink-soft hover:text-cobalt-dark hover:border-cobalt/40 transition-colors"
|
||||
>
|
||||
<span>CSE</span>
|
||||
<span className="text-[0.65rem] text-ink-mute">↗</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://it.knu.ac.kr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-ivory border border-line text-ink-soft hover:text-cobalt-dark hover:border-cobalt/40 transition-colors"
|
||||
>
|
||||
<span>CITE</span>
|
||||
<span className="text-[0.65rem] text-ink-mute">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="lg:col-span-4">
|
||||
<p className="kicker text-ink-mute">Contact</p>
|
||||
<address className="mt-3 not-italic text-sm leading-relaxed text-ink-soft">
|
||||
(41566) 대구광역시 북구 대학로 80<br />
|
||||
경북대학교 IT대학 5호관(415동) 527호<br />
|
||||
<div className="lg:col-span-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-px w-6 bg-cobalt-dark"></span>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-wider text-cobalt-dark">
|
||||
Contact
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<address className="not-italic text-sm leading-relaxed text-ink-soft space-y-2">
|
||||
<div>
|
||||
<div className="font-semibold text-ink">Prof. Seok-Joo Koh</div>
|
||||
<a
|
||||
href="mailto:sjkoh@knu.ac.kr"
|
||||
className="draw-underline mt-2 inline-block text-ink hover:text-vermillion font-medium"
|
||||
className="inline-block text-xs font-mono text-cobalt-dark hover:underline mt-0.5"
|
||||
>
|
||||
sjkoh@knu.ac.kr
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-ink-mute leading-normal pt-1.5 border-t border-line/50">
|
||||
Room 527, Bldg. 415 (IT-5), College of IT Engineering<br />
|
||||
Kyungpook National University<br />
|
||||
80 Daehak-ro, Buk-gu, Daegu 41566, Republic of Korea
|
||||
</div>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
{/* Index */}
|
||||
<div className="lg:col-span-3">
|
||||
<p className="kicker text-ink-mute">Index</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{/* Navigation Index */}
|
||||
<div className="lg:col-span-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-px w-6 bg-cobalt-dark"></span>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-wider text-cobalt-dark">
|
||||
Index
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{links.map((l, i) => (
|
||||
<li key={l.href} className="flex items-baseline gap-3">
|
||||
<span className="font-display text-xs text-ink-mute">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<li key={l.href}>
|
||||
<Link
|
||||
href={l.href}
|
||||
className="text-sm text-ink-soft transition hover:text-vermillion"
|
||||
className="group flex items-center justify-between text-xs font-mono text-ink-soft hover:text-cobalt-dark transition-colors py-1 border-b border-line/40 hover:border-cobalt/40"
|
||||
>
|
||||
{l.ko}{" "}
|
||||
<span className="text-xs text-ink-mute">{l.en}</span>
|
||||
<span className="font-sans text-sm text-ink-soft group-hover:text-cobalt-dark">
|
||||
{l.en}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-mute group-hover:text-cobalt-dark">
|
||||
{String(i + 1).padStart(2, "0")} →
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -83,9 +173,8 @@ export default function Footer() {
|
||||
<div className="border-t border-line">
|
||||
<div className="container-content flex flex-col gap-2 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-ink-mute">
|
||||
© {new Date().getFullYear()} AI 에이전트 네트워크 연구실 (ANL) · 경북대학교 컴퓨터학부. All rights reserved.
|
||||
© {new Date().getFullYear()} AI Agent Networking Lab (ANL) · School of Computer Science and Engineering, KNU. All rights reserved.
|
||||
</p>
|
||||
<p className="kicker text-ink-mute">Set in Fraunces · Noto Serif KR · Pretendard</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -2,61 +2,177 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
// CUSTOMIZATION HOOK — MOTION: 스마트 스티키 튜닝
|
||||
const SCROLL_DELTA = 6; // px — 방향 전환 인식 최소 이동량(지터 방지)
|
||||
const HIDE_AFTER = 140; // px — 이 지점 이전에는 숨기지 않음(실측 데스크톱 헤더 높이 122.8px + 여유)
|
||||
const NAV_SETTLE_MS = 300; // ms — 라우트 전환 직후 방향 판정 유예 구간
|
||||
|
||||
// CUSTOMIZATION HOOK: edit nav labels / routes here.
|
||||
const navItems = [
|
||||
{ href: "/", ko: "홈", en: "Home", no: "01" },
|
||||
{ href: "/members", ko: "구성원", en: "Members", no: "02" },
|
||||
{ href: "/publications/intl-journal-conf", ko: "논문", en: "Publications", no: "03" },
|
||||
{ href: "/lectures", ko: "강의", en: "Lectures", no: "04" },
|
||||
{ href: "/standardization", ko: "표준화", en: "Standardization", no: "05" },
|
||||
{ href: "/", en: "Home", no: "01" },
|
||||
{ href: "/members", en: "Members", no: "02" },
|
||||
{ href: "/publications", en: "Publications", no: "03" },
|
||||
{ href: "/lectures", en: "Lectures", no: "04" },
|
||||
{ href: "/standardization", en: "Standardization", no: "05" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const lastY = useRef(0);
|
||||
const ticking = useRef(false);
|
||||
const navSettling = useRef(false);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
|
||||
useEffect(() => {
|
||||
// (A) 모션 줄임 사용자는 자동 숨김 자체를 비활성화 — DESIGN.md 접근성 원칙
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
lastY.current = window.scrollY;
|
||||
|
||||
const update = () => {
|
||||
ticking.current = false;
|
||||
const y = window.scrollY;
|
||||
|
||||
// (B) 최상단 + iOS 러버밴드(음수) 구간 → 항상 표시
|
||||
if (y <= 0) {
|
||||
setHidden(false);
|
||||
lastY.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// (F-2) 라우트 전환 정착 구간: 전환이 유발한 스크롤 점프는 방향 판정에서 제외.
|
||||
// 기준점만 따라가고 hidden은 건드리지 않는다.
|
||||
if (navSettling.current) {
|
||||
lastY.current = y;
|
||||
return;
|
||||
}
|
||||
|
||||
// (C) 모바일 메뉴가 열려 있으면 숨기지 않되, lastY는 계속 추적
|
||||
// (추적하지 않으면 메뉴를 닫는 순간 헤더가 튀며 사라짐)
|
||||
if (openRef.current) {
|
||||
setHidden(false);
|
||||
lastY.current = y;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = y - lastY.current;
|
||||
|
||||
// (D) 미세 이동 무시. 이때 lastY를 갱신하지 않고 보존
|
||||
if (Math.abs(delta) < SCROLL_DELTA) return;
|
||||
|
||||
if (delta > 0 && y > HIDE_AFTER) {
|
||||
setHidden(true); // 아래로 → 숨김
|
||||
} else if (delta < 0) {
|
||||
setHidden(false); // 위로 → 복귀
|
||||
}
|
||||
|
||||
lastY.current = y;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (ticking.current) return;
|
||||
ticking.current = true;
|
||||
requestAnimationFrame(update); // 프레임당 1회로 제한
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// (F) 라우트 전환 대응 — persistent layout이라 hidden이 페이지를 넘어 생존한다.
|
||||
// 스크롤 이벤트 발화 여부와 무관하게 표시 상태를 보장한다.
|
||||
useEffect(() => {
|
||||
setHidden(false);
|
||||
lastY.current = window.scrollY; // 현재 실제 위치로 동기화
|
||||
setOpen(false); // 뒤로/앞으로 이동 시 모바일 메뉴 잔존 방지
|
||||
navSettling.current = true;
|
||||
const id = window.setTimeout(() => {
|
||||
navSettling.current = false;
|
||||
}, NAV_SETTLE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [pathname]);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname
|
||||
? href === "/"
|
||||
? pathname === "/"
|
||||
: pathname === href || pathname.startsWith(`${href}/`) || (href.startsWith("/publications") && pathname.startsWith("/publications"))
|
||||
: pathname === href || pathname.startsWith(`${href}/`)
|
||||
: false;
|
||||
|
||||
if (pathname && pathname.startsWith("/console")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-ink bg-ivory/85 backdrop-blur py-2 sm:py-3">
|
||||
<header
|
||||
data-smart-sticky
|
||||
onFocusCapture={() => setHidden(false)}
|
||||
className={`flow-root sticky top-0 z-50 border-b border-ink bg-ivory/85 backdrop-blur transition-transform duration-300 ease-out ${
|
||||
hidden ? "-translate-y-full" : "translate-y-0"
|
||||
}`}
|
||||
>
|
||||
{/* Journal issue strip — magazine masthead feel. */}
|
||||
<div className="hidden border-b border-line pb-2 mb-2 desktop:block">
|
||||
<div className="hidden border-b border-line desktop:block">
|
||||
<div className="container-content flex items-center justify-between py-1.5">
|
||||
<span className="kicker">KNU/CSE/ANL</span>
|
||||
{/* CUSTOMIZATION HOOK: masthead tagline */}
|
||||
<span className="kicker text-ink-mute">
|
||||
Kyungpook National University · School of Computer Science & Engineering
|
||||
</span>
|
||||
<span className="kicker">대구 · Daegu</span>
|
||||
<span className="kicker">Daegu / South Korea</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container-content flex min-h-[4.5rem] items-center justify-between gap-8 py-2 sm:py-3">
|
||||
{/* Brand logo — explicit right margin to guarantee clear separation from 01 HOME */}
|
||||
<div className="container-content flex h-[4.25rem] items-center justify-between gap-4 my-2 sm:my-3">
|
||||
{/* Brand logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex flex-col leading-none shrink-0 mr-8 xl:mr-16"
|
||||
className="group flex items-center gap-3 min-w-0 nav:shrink-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="headline-ko text-lg text-ink sm:text-xl">
|
||||
AI 에이전트 네트워크 연구실
|
||||
</span>
|
||||
<span className="kicker mt-1 group-hover:text-vermillion">
|
||||
AI Agent Networking Lab (ANL)
|
||||
</span>
|
||||
<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%">
|
||||
<stop offset="0%" stopColor="#4E77FF"/>
|
||||
<stop offset="100%" stopColor="#2B4CDE"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="nodeGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#FFD666" stopOpacity="1"/>
|
||||
<stop offset="100%" stopColor="#FF9C1A" stopOpacity="0.8"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#headerLogoGrad)"/>
|
||||
{/* Network Graph Edges */}
|
||||
<path d="M16 9 L9 22 M16 9 L23 22 M9 22 L23 22 M16 9 L16 17 M9 22 L16 17 M23 22 L16 17"
|
||||
stroke="#FFFFFF" strokeWidth="1.2" strokeOpacity="0.85" strokeLinecap="round"/>
|
||||
{/* Network Agent Outer Nodes */}
|
||||
<circle cx="16" cy="9" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="9" cy="22" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="23" cy="22" r="2.5" fill="#FFFFFF"/>
|
||||
{/* Central Coordinating Hub Node */}
|
||||
<circle cx="16" cy="17" r="2.2" fill="url(#nodeGlow)" className="animate-pulse"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Stacked wordmark — the cobalt initials spell out ANL */}
|
||||
<div className="font-sans flex flex-col min-w-0 text-[0.8rem] sm:text-[0.875rem] font-semibold leading-[1.15] tracking-tight text-ink transition-colors group-hover:text-cobalt-dark">
|
||||
<span className="whitespace-nowrap"><span className="text-cobalt">A</span>I Agent</span>
|
||||
<span className="whitespace-nowrap"><span className="text-cobalt">N</span>etworking</span>
|
||||
<span className="whitespace-nowrap"><span className="text-cobalt">L</span>aboratory</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav — small caps, wide tracking (visible at >= 1240px) */}
|
||||
<div className="hidden items-center gap-6 desktop:flex ml-auto">
|
||||
<nav className="flex items-center gap-2" aria-label="주 메뉴">
|
||||
{/* Desktop nav — small caps, wide tracking (visible at >= 1000px; compact spacing below 1240px) */}
|
||||
<div className="hidden items-center gap-3 nav:flex desktop:gap-4">
|
||||
<nav className="flex items-center gap-2" aria-label="Main Navigation">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
@@ -64,28 +180,20 @@ export default function Header() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
style={
|
||||
className={`group relative flex items-baseline gap-1.5 text-[0.78rem] font-semibold uppercase tracking-[0.1em] desktop:tracking-[0.18em] transition-all px-2 desktop:px-3 py-1.5 rounded-md ${
|
||||
active
|
||||
? { backgroundColor: "var(--vermillion)", color: "#ffffff" }
|
||||
: undefined
|
||||
}
|
||||
className={`group relative flex items-baseline gap-1.5 text-[0.78rem] font-bold uppercase tracking-[0.18em] transition-all px-3.5 py-1.5 rounded-md ${
|
||||
active
|
||||
? "shadow-sm font-bold"
|
||||
: "text-ink hover:text-vermillion hover:bg-paper/60"
|
||||
? "bg-cobalt-dark text-white dark:text-ivory shadow-sm font-bold"
|
||||
: "text-ink hover:text-cobalt-dark hover:bg-paper/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
style={active ? { color: "#ffffff" } : undefined}
|
||||
className={`font-display text-[0.62rem] ${
|
||||
active ? "opacity-90 font-bold" : "text-ink-mute"
|
||||
active ? "text-white dark:text-ivory" : "text-ink-mute"
|
||||
}`}
|
||||
>
|
||||
{item.no}
|
||||
</span>
|
||||
<span style={active ? { color: "#ffffff" } : undefined}>
|
||||
{item.en}
|
||||
</span>
|
||||
<span>{item.en}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -94,13 +202,13 @@ export default function Header() {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile controls (visible under 1240px) */}
|
||||
<div className="flex items-center gap-2 desktop:hidden">
|
||||
{/* Mobile controls (visible under 1000px) */}
|
||||
<div className="flex items-center gap-2 nav:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center border border-ink p-2 text-ink transition hover:bg-ink hover:text-ivory"
|
||||
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -120,8 +228,8 @@ export default function Header() {
|
||||
{open && (
|
||||
<nav
|
||||
id="mobile-menu"
|
||||
className="border-t border-ink bg-ivory desktop:hidden"
|
||||
aria-label="모바일 메뉴"
|
||||
className="mobile-menu-panel border-t border-ink bg-ivory nav:hidden"
|
||||
aria-label="Mobile Navigation"
|
||||
>
|
||||
<ul className="container-content flex flex-col py-2">
|
||||
{navItems.map((item) => {
|
||||
@@ -132,40 +240,29 @@ export default function Header() {
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
style={
|
||||
active
|
||||
? { backgroundColor: "var(--vermillion)", color: "#ffffff" }
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-baseline justify-between px-4 py-3.5 transition-colors rounded-md my-1 ${
|
||||
active
|
||||
? "font-bold shadow-sm"
|
||||
? "bg-cobalt-dark text-white dark:text-ivory font-bold shadow-sm"
|
||||
: "text-ink hover:bg-paper/60"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-baseline gap-3">
|
||||
<span
|
||||
style={active ? { color: "#ffffff" } : undefined}
|
||||
className={`font-display text-xs ${
|
||||
active ? "opacity-90 font-bold" : "text-ink-mute"
|
||||
active ? "text-white dark:text-ivory" : "text-ink-mute"
|
||||
}`}
|
||||
>
|
||||
{item.no}
|
||||
</span>
|
||||
<span
|
||||
style={active ? { color: "#ffffff" } : undefined}
|
||||
className="headline-ko text-lg font-bold"
|
||||
>
|
||||
{item.ko}
|
||||
</span>
|
||||
<span className="font-sans text-base">{item.en}</span>
|
||||
</span>
|
||||
<span
|
||||
style={active ? { color: "#ffffff" } : undefined}
|
||||
className={`text-[0.7rem] font-semibold uppercase tracking-[0.18em] ${
|
||||
active ? "opacity-90 font-bold" : "text-ink-mute"
|
||||
aria-hidden="true"
|
||||
className={`text-xs font-mono ${
|
||||
active ? "text-white/80 dark:text-ivory/80" : "text-ink-mute"
|
||||
}`}
|
||||
>
|
||||
{item.en}
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ElementType, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type ElementType, type ReactNode, type HTMLAttributes } from "react";
|
||||
|
||||
type Variant = "up" | "left" | "right" | "scale";
|
||||
|
||||
@@ -18,7 +18,9 @@ export default function Reveal({
|
||||
delay = 0,
|
||||
as: Tag = "div",
|
||||
className = "",
|
||||
}: {
|
||||
style,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
delay?: number;
|
||||
@@ -47,12 +49,17 @@ export default function Reveal({
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
const combinedStyle = delay
|
||||
? { ...style, transitionDelay: `${delay}ms` }
|
||||
: style;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref}
|
||||
data-variant={variant}
|
||||
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
|
||||
style={combinedStyle}
|
||||
className={`reveal ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PubCategory } from "./types";
|
||||
export interface CategoryInfo {
|
||||
slug: PubCategory;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export const PUB_CATEGORIES: CategoryInfo[] = [
|
||||
{
|
||||
slug: "patent",
|
||||
label: "Patent",
|
||||
subtitle: "(IoT Architecture & Protocols, etc ...)",
|
||||
desc: "국내외 지식재산권 출원 및 등록 실적",
|
||||
},
|
||||
];
|
||||
@@ -1,544 +0,0 @@
|
||||
// AUTO-GENERATED by scripts/extract/generate_all.py — DO NOT EDIT BY HAND
|
||||
import { Member, Alumnus } from "./types";
|
||||
|
||||
export const activeMembers: Member[] = [
|
||||
{
|
||||
"ko": "고석주",
|
||||
"en": "Seok-Joo Koh",
|
||||
"degree": "Professor"
|
||||
},
|
||||
{
|
||||
"ko": "최동규",
|
||||
"en": "Dong-Kyu Choi",
|
||||
"degree": "PhD"
|
||||
},
|
||||
{
|
||||
"ko": "남혜빈",
|
||||
"en": "Hye-Been Nam",
|
||||
"degree": "PhDCandidate"
|
||||
},
|
||||
{
|
||||
"ko": "임도현",
|
||||
"en": "Dohyeon Lim",
|
||||
"degree": "PhDStudent"
|
||||
},
|
||||
{
|
||||
"ko": "최준혁",
|
||||
"en": "Jun-Hyeok Choi",
|
||||
"degree": "PhDStudent"
|
||||
},
|
||||
{
|
||||
"ko": "신광선",
|
||||
"en": "Gwang-Seon Shin",
|
||||
"degree": "MSCandidate"
|
||||
},
|
||||
{
|
||||
"ko": "이환웅",
|
||||
"en": "Hwan-Woong Lee",
|
||||
"degree": "MSStudent"
|
||||
},
|
||||
{
|
||||
"ko": "Hoang Anh Dang",
|
||||
"en": "Hoang Anh Dang",
|
||||
"degree": "MSStudent"
|
||||
},
|
||||
{
|
||||
"ko": "김민희",
|
||||
"en": "Minhee Kim",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": "School of Computer Science and Engineering"
|
||||
},
|
||||
{
|
||||
"ko": "김근솔",
|
||||
"en": "Keun-Sol Kim",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": "Department of Information Security"
|
||||
},
|
||||
{
|
||||
"ko": "민성준",
|
||||
"en": "Sung-Jun Min",
|
||||
"degree": "PhDCandidate",
|
||||
"affiliation": "Department of Information Science"
|
||||
},
|
||||
{
|
||||
"ko": "최진원",
|
||||
"en": "Jin-Won Choi",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": "Department of Science and Technology"
|
||||
},
|
||||
{
|
||||
"ko": "정기수",
|
||||
"en": "Ki-Soo Jung",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": "Department of Science and Technology"
|
||||
},
|
||||
{
|
||||
"ko": "이미주",
|
||||
"en": "Mi-Joo Lee",
|
||||
"degree": "PhDStudent",
|
||||
"affiliation": "Department of ICT Convergence"
|
||||
},
|
||||
{
|
||||
"ko": "박유나",
|
||||
"en": "Yunah Park",
|
||||
"degree": "MSCandidate",
|
||||
"affiliation": "Department of Information Security"
|
||||
},
|
||||
{
|
||||
"ko": "박채영",
|
||||
"en": "Chae-Young Park",
|
||||
"degree": "MSCandidate",
|
||||
"affiliation": "Department of Information Science"
|
||||
}
|
||||
];
|
||||
|
||||
export const alumni: Alumnus[] = [
|
||||
{
|
||||
"ko": "김동주",
|
||||
"en": "Dongju Kim",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2026-08",
|
||||
"major": "Computer Science and Engineering",
|
||||
"currentPosition": "He is now with Daegu Catholic University (대구가톨릭대학교) as Professor"
|
||||
},
|
||||
{
|
||||
"ko": "황정미",
|
||||
"en": "Jung-Mi Hwang",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2026-08",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "She is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "배드로",
|
||||
"en": "Dro Bae",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2026-02",
|
||||
"major": "Industrial Engineering",
|
||||
"currentPosition": "He is now with SK AX"
|
||||
},
|
||||
{
|
||||
"ko": "권세기",
|
||||
"en": "Se-Gi Kwon",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2026-02",
|
||||
"major": "Information Security",
|
||||
"currentPosition": "He is now with J Solution (제이솔루션) as CEO"
|
||||
},
|
||||
{
|
||||
"ko": "조세현",
|
||||
"en": "Se-Hyun Cho",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2025-08",
|
||||
"major": "Information Security",
|
||||
"currentPosition": "He is now with Cyber Security Center"
|
||||
},
|
||||
{
|
||||
"ko": "김윤성",
|
||||
"en": "Yun-Seong Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2025-08",
|
||||
"major": "Data Convergence Computing",
|
||||
"currentPosition": "He is now with SL (에스엘)"
|
||||
},
|
||||
{
|
||||
"ko": "김수진",
|
||||
"en": "Su-Jin Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2025-08",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "김상태",
|
||||
"en": "Sang-Tae Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2025-08",
|
||||
"major": "Industrial Engineering",
|
||||
"currentPosition": "He is now with Oh-Sung Electronis (오성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김규범",
|
||||
"en": "Gyu-Bum Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2025-08",
|
||||
"major": "Computer Science and Engineering",
|
||||
"currentPosition": "He is now with Korea Real Estate Board (한국부동산원)"
|
||||
},
|
||||
{
|
||||
"ko": "정중화",
|
||||
"en": "Joong-Hwa Jung",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2025-02",
|
||||
"currentPosition": "He is now with Lychee AI Coding Academy (리치AI코딩학원) as CEO"
|
||||
},
|
||||
{
|
||||
"ko": "임도현",
|
||||
"en": "Dohyeon Lim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2025-02",
|
||||
"major": "Computer Science and Engineering",
|
||||
"currentPosition": "He is now with TakenSoft (테이큰소프트)"
|
||||
},
|
||||
{
|
||||
"ko": "김소용",
|
||||
"en": "So-Yong Kim",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2024-08",
|
||||
"currentPosition": "He is now with Catholic University of Leuven (UCLouvain) in Belgium"
|
||||
},
|
||||
{
|
||||
"ko": "김민지",
|
||||
"en": "Min-Ji Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2024-02",
|
||||
"currentPosition": "She is now with LG Electronics"
|
||||
},
|
||||
{
|
||||
"ko": "안은지",
|
||||
"en": "Eun-Ji Ahn",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2024-02",
|
||||
"currentPosition": "S is now with National Security Research Institute (국가보안연구소)"
|
||||
},
|
||||
{
|
||||
"ko": "오동규",
|
||||
"en": "Dong-Kyu Oh",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2024-02",
|
||||
"major": "Industrial Engineering",
|
||||
"currentPosition": "He is now with Human-Plus (휴먼플러스)"
|
||||
},
|
||||
{
|
||||
"ko": "김재성",
|
||||
"en": "Jae-Seong Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2023-08",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "최동규",
|
||||
"en": "Dong-Kyu Choi",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2023-02",
|
||||
"currentPosition": "He is now with ISL in KNU"
|
||||
},
|
||||
{
|
||||
"ko": "최진원",
|
||||
"en": "Jin-Won Choi",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2023-02",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "김지은",
|
||||
"en": "Ji-Eun Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2023-02",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "She is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "김철민",
|
||||
"en": "Cheol-Min Kim",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2022-08",
|
||||
"currentPosition": "He is now with KETI (한국전자기술연구원)"
|
||||
},
|
||||
{
|
||||
"ko": "김경식",
|
||||
"en": "Kyung-Sik Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2022-02",
|
||||
"currentPosition": "He is now with Hyundai Autoever (현대오토에버)"
|
||||
},
|
||||
{
|
||||
"ko": "김근수",
|
||||
"en": "Keun-Soo Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2022-02",
|
||||
"currentPosition": "He is now with Shinhan Bank (신한은행)"
|
||||
},
|
||||
{
|
||||
"ko": "정성우",
|
||||
"en": "Seong-Woo Jeong",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2022-02",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "최민철",
|
||||
"en": "Min-Cheol Choi",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2022-02",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "장준희",
|
||||
"en": "Jun-Hee Jang",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2021-08",
|
||||
"major": "Computer Science and Engineering",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "Muhammad Hafidh Firmansyah",
|
||||
"en": "Muhammad Hafidh Firmansyah",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2021-08",
|
||||
"currentPosition": "He is now with State Polytechnic University of Jember in Indonesia"
|
||||
},
|
||||
{
|
||||
"ko": "장강민",
|
||||
"en": "Kang-Min Jang",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2021-02",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "이홍근",
|
||||
"en": "Hong-Keun Lee",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2021-02",
|
||||
"major": "Information Science",
|
||||
"currentPosition": "He is now with NIA (한국지능정보사회진흥원)"
|
||||
},
|
||||
{
|
||||
"ko": "남혜빈",
|
||||
"en": "Hyebeen Nam",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2021-02",
|
||||
"currentPosition": "She is now with ISL in KNU"
|
||||
},
|
||||
{
|
||||
"ko": "최낙중",
|
||||
"en": "Nak-Jung Choi",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2020-02",
|
||||
"currentPosition": "He is now with Agency for Defense Development (국방과학연구소)"
|
||||
},
|
||||
{
|
||||
"ko": "정민우",
|
||||
"en": "Min-Woo Jung",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2019-02",
|
||||
"currentPosition": "He is now with LG Electronics (LG전자)"
|
||||
},
|
||||
{
|
||||
"ko": "강형우",
|
||||
"en": "Hyung-Woo Kang",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2018-08",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "최예찬",
|
||||
"en": "Ye-Chan Choi",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2018-02",
|
||||
"currentPosition": "He is now with LG-CNS"
|
||||
},
|
||||
{
|
||||
"ko": "최상일",
|
||||
"en": "Sang-Il Choi",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2017-02",
|
||||
"currentPosition": "He is now with Daegu Catholic University (대구가톨릭대학교) as a professor"
|
||||
},
|
||||
{
|
||||
"ko": "박진호",
|
||||
"en": "Jin-Ho Park",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2016-08",
|
||||
"currentPosition": "He is now with BiThumb (빗썸코리아)"
|
||||
},
|
||||
{
|
||||
"ko": "석성윤",
|
||||
"en": "Sung-Yoon Seok",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2016-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김진규",
|
||||
"en": "Jin-Kyu Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2016-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김재철",
|
||||
"en": "Jae-Cheol Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2016-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김지인",
|
||||
"en": "Ji-In Kim",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2016-02",
|
||||
"currentPosition": "He is now with FAM Tech. (팸텍) as CEO"
|
||||
},
|
||||
{
|
||||
"ko": "김우주",
|
||||
"en": "Woo-Ju Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2016-02",
|
||||
"currentPosition": "He is now with ZCube (지큐브)"
|
||||
},
|
||||
{
|
||||
"ko": "박정섭",
|
||||
"en": "Jung-Sub Park",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2015-08",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "최종명",
|
||||
"en": "Jong-Myoung Choi",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2015-02",
|
||||
"currentPosition": "He is now with NP Communications"
|
||||
},
|
||||
{
|
||||
"ko": "이종관",
|
||||
"en": "Jong-Kwan Lee",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2014-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Hanwha Ocean (한화오션)"
|
||||
},
|
||||
{
|
||||
"ko": "이상헌",
|
||||
"en": "Sang-Hun Lee",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2014-02",
|
||||
"currentPosition": "He is now with Inno Wireless"
|
||||
},
|
||||
{
|
||||
"ko": "민경욱",
|
||||
"en": "Kyeong-Wook Min",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2013-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김상헌",
|
||||
"en": "Sang-Heon Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2013-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "Moneeb Gohar",
|
||||
"en": "Moneeb Gohar",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2012-08",
|
||||
"currentPosition": "He is now with Department of Computer Science, Bahria University (at Islamabad) in Pakistan"
|
||||
},
|
||||
{
|
||||
"ko": "박재완",
|
||||
"en": "Jae-Wan Park",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2012-02",
|
||||
"currentPosition": "He is now with Ministry of Justice (Immigration Information Center, 법무부)"
|
||||
},
|
||||
{
|
||||
"ko": "이재경",
|
||||
"en": "Jae-Kyoung Lee",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2012-02",
|
||||
"currentPosition": "She is now with Korea Transportation Satefy Authority (한국교통안전공단)"
|
||||
},
|
||||
{
|
||||
"ko": "하원기",
|
||||
"en": "Won-Ki Ha",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2012-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김근희",
|
||||
"en": "Keun-Hee Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2012-02",
|
||||
"major": "Samsung Electronics Program",
|
||||
"currentPosition": "She is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "권순홍",
|
||||
"en": "Soon-Hong Kwon",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2010-02",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "김동필",
|
||||
"en": "Dong-Phil Kim",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2009-02",
|
||||
"currentPosition": "He is now with National Security Research Institute (국가보안연구소)"
|
||||
},
|
||||
{
|
||||
"ko": "최린",
|
||||
"en": "Lin Cui",
|
||||
"degree": "PhD",
|
||||
"graduatedAt": "2009-02",
|
||||
"currentPosition": "He is now with Tianjin University of Technology and Education in China"
|
||||
},
|
||||
{
|
||||
"ko": "이동화",
|
||||
"en": "Dong-Hwa Lee",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2009-02",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
},
|
||||
{
|
||||
"ko": "박재성",
|
||||
"en": "Jae-Sung Park",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2008-08",
|
||||
"currentPosition": "He is now with Gom&Company (곰앤컴퍼니)"
|
||||
},
|
||||
{
|
||||
"ko": "주수경",
|
||||
"en": "Su-Kyoung Ju",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2008-02",
|
||||
"currentPosition": "She is now with Youngjin Univ. (영진전문대학교)"
|
||||
},
|
||||
{
|
||||
"ko": "윤성식",
|
||||
"en": "Sung-Shik Yoon",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2007-08",
|
||||
"currentPosition": "He is now with Jo-Il Textile Processing Company (조일가공) as CEO"
|
||||
},
|
||||
{
|
||||
"ko": "김상태",
|
||||
"en": "Sang-Tae Kim",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2007-02",
|
||||
"currentPosition": "He is now with LG Electronics (LG전자)"
|
||||
},
|
||||
{
|
||||
"ko": "하종식",
|
||||
"en": "Jong-Shik Ha",
|
||||
"degree": "MS",
|
||||
"graduatedAt": "2007-02",
|
||||
"currentPosition": "He is now with Samsung Electronics (삼성전자)"
|
||||
}
|
||||
];
|
||||
@@ -1,92 +0,0 @@
|
||||
// AUTO-GENERATED by scripts/extract/generate_all.py — DO NOT EDIT BY HAND
|
||||
import { ResearchProject } from "./types";
|
||||
|
||||
export const researchProjects: ResearchProject[] = [
|
||||
{
|
||||
"slug": "iot-standards",
|
||||
"title": "IoT Standards",
|
||||
"abstract": "Internet of Things covers a huge range of industries and use cases that scale from a single constrained device up to massive cross-platform deployments of embedded technologies and cloud systems connecting in real-time.",
|
||||
"keywords": [
|
||||
"Internet of Things",
|
||||
"embedded technologies",
|
||||
"cloud systems",
|
||||
"real-time"
|
||||
],
|
||||
"standardsOrg": "ISO/IEC JTC1/SC41",
|
||||
"period": "2020 ~ Present",
|
||||
"deliverables": [
|
||||
{
|
||||
"title": "Part 1: Framework",
|
||||
"ref": "ISO/IEC TR 30189-1 (March 2025)",
|
||||
"status": "TR"
|
||||
},
|
||||
{
|
||||
"title": "Part 2: Use Cases",
|
||||
"ref": "ISO/IEC CDTR 30189-2 (In Progress)",
|
||||
"status": "InProgress"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "ccis",
|
||||
"title": "CCIS",
|
||||
"abstract": "In-Vehicle Infotainment (IVI) refers to vehicle systems that combine entertainment and information delivery to drivers and passengers. Configurable Car Infotainment Service (CCIS) is a service that helps users to more easily manage and control In-Vehicle infotainment devices and content.",
|
||||
"keywords": [
|
||||
"In-Vehicle Infotainment",
|
||||
"CCIS",
|
||||
"infotainment devices"
|
||||
],
|
||||
"standardsTrack": "IEC/TC 100/TA 17",
|
||||
"standardsOrg": "IEC TC100",
|
||||
"period": "2018 ~ Present",
|
||||
"deliverables": [
|
||||
{
|
||||
"title": "Configurable Car Infotainment Services (CCIS) - Part 1: General",
|
||||
"ref": "IEC 63246-1 (August 2021)",
|
||||
"status": "IS"
|
||||
},
|
||||
{
|
||||
"title": "Configurable Car Infotainment Services (CCIS) - Part 2: Requirements",
|
||||
"ref": "IEC 63246-2 (January 2022)",
|
||||
"status": "IS"
|
||||
},
|
||||
{
|
||||
"title": "Configurable Car Infotainment Services (CCIS) - Part 3: Framework",
|
||||
"ref": "IEC 63246-3 (January 2022)",
|
||||
"status": "IS"
|
||||
},
|
||||
{
|
||||
"title": "Configurable Car Infotainment Services (CCIS) - Part 4: Protocol",
|
||||
"ref": "IEC TR 63246-4 (December 2022)",
|
||||
"status": "TR"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "vlc-iot",
|
||||
"title": "VLC-IoT",
|
||||
"abstract": "Visible-light communication (VLC) transmits data by intensity modulating optical sources, such as light-emitting diodes (LEDs) and laser diodes (LDs), faster than the persistence of the human eye. The goal of IoT-VLC is to design a framework of IoT services based on VLC.",
|
||||
"keywords": [
|
||||
"Visible-light communication",
|
||||
"VLC",
|
||||
"light-emitting diodes",
|
||||
"laser diodes",
|
||||
"IoT services"
|
||||
],
|
||||
"standardsTrack": "ITU-T SG20",
|
||||
"standardsOrg": "ITU-T SG20",
|
||||
"period": "2018 ~ 2020",
|
||||
"deliverables": [
|
||||
{
|
||||
"title": "Framework of IoT Services based on Visible Light Communications (VLC)",
|
||||
"ref": "ITU-T Recommendation Y.4465 (January 2020)",
|
||||
"status": "IS"
|
||||
},
|
||||
{
|
||||
"title": "Functional Architecture for IoT Services based on VLC",
|
||||
"ref": "ITU-T Recommendation Y.4474 (August 2020)",
|
||||
"status": "IS"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -31,6 +31,7 @@ export interface Publication {
|
||||
publishedAt: string; // ISO 8601 YYYY-MM or YYYY-MM-DD
|
||||
kci?: boolean;
|
||||
doi?: string;
|
||||
isHighlight?: boolean;
|
||||
}
|
||||
|
||||
export interface Patent {
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
| `line` | `#D8D1C0` | `--line` | 헤어라인 그리드 간격(`gap-px bg-line`) |
|
||||
| `vermillion` (DEFAULT) | `#D9342B` | `--vermillion` | **Agent Eng / Orchestration 강조(Hot)** · 홀수 항목 |
|
||||
| `vermillion-dark` | `#A8231C` | — | vermillion 의 호버/심도(dim) 변형 |
|
||||
| `cobalt` (DEFAULT) | `#1B3A8A` | `--cobalt` | **MCP / A2A Protocol 강조(Cool)** · 짝수 항목 |
|
||||
| `cobalt-dark` | `#122863` | — | cobalt 의 호버/심도(dim) 변형 |
|
||||
| `cobalt` (DEFAULT) | `#4E77FF` | `--cobalt` | **MCP / A2A Protocol 강조(Cool)** · 짝수 항목 |
|
||||
| `cobalt-dark` | `#345AD2` | — | cobalt 의 호버/심도(dim) 변형 및 소형 텍스트/상태 칩 전용 접근성 변형 |
|
||||
| `brand`, `accent` | (별칭) | — | **레거시 호환 전용.** 신규 코드에서 사용 금지 |
|
||||
|
||||
### 개념적 역할 매핑 (Naming note)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type {
|
||||
Member,
|
||||
Alumnus,
|
||||
Publication,
|
||||
Patent,
|
||||
Semester,
|
||||
StandardsBody,
|
||||
ResearchProject,
|
||||
PubCategory,
|
||||
DocStatus,
|
||||
} from "../content/types";
|
||||
|
||||
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> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// Raw backend response schemas (snake_case)
|
||||
interface BackendResearchProject {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
abstract: string;
|
||||
keywords: string[];
|
||||
organization?: string | null;
|
||||
standards_org?: string | null;
|
||||
period?: string | null;
|
||||
funder?: string | null;
|
||||
}
|
||||
|
||||
interface BackendResearchArea {
|
||||
id: number;
|
||||
name_en: string;
|
||||
name_kr?: string | null;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
interface BackendStatsSummary {
|
||||
intl_publications: number;
|
||||
standardization_docs: number;
|
||||
patents: number;
|
||||
}
|
||||
|
||||
interface BackendMember {
|
||||
id: number;
|
||||
name_ko: string;
|
||||
name_en: string;
|
||||
degree: string;
|
||||
affiliation?: string | null;
|
||||
email?: string | null;
|
||||
is_advisor: boolean;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
interface BackendAlumnus {
|
||||
id: number;
|
||||
name_ko: string;
|
||||
name_en: string;
|
||||
degree: string;
|
||||
graduated_at: string;
|
||||
major?: string | null;
|
||||
current_position?: string | null;
|
||||
}
|
||||
|
||||
interface BackendPublication {
|
||||
id: number;
|
||||
category: string;
|
||||
title: string;
|
||||
authors?: string | null;
|
||||
venue: string;
|
||||
volume?: string | null;
|
||||
published_at: string;
|
||||
kci: boolean;
|
||||
doi?: string | null;
|
||||
is_highlight: boolean;
|
||||
}
|
||||
|
||||
interface BackendPatent {
|
||||
id: number;
|
||||
title: string;
|
||||
inventors: string;
|
||||
application_no: string;
|
||||
application_at: string;
|
||||
registration_no?: string | null;
|
||||
registration_at?: string | null;
|
||||
country: string;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface BackendCourse {
|
||||
id: number;
|
||||
semester_id: number;
|
||||
code: string;
|
||||
name_en: string;
|
||||
name_kr?: string | null;
|
||||
note?: string | null;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
interface BackendSemester {
|
||||
id: number;
|
||||
year: number;
|
||||
term: string;
|
||||
courses: BackendCourse[];
|
||||
}
|
||||
|
||||
interface BackendStandardDoc {
|
||||
id: number;
|
||||
body_id: number;
|
||||
wg: string;
|
||||
project_name: string;
|
||||
title: string;
|
||||
doc_ref: string;
|
||||
status: string;
|
||||
published_at?: string | null;
|
||||
}
|
||||
|
||||
interface BackendStandardProject {
|
||||
wg: string;
|
||||
name: string;
|
||||
documents: BackendStandardDoc[];
|
||||
}
|
||||
|
||||
interface BackendStandardsBody {
|
||||
id: number;
|
||||
org: string;
|
||||
full_name: string;
|
||||
scope: string;
|
||||
period: string;
|
||||
role?: string | null;
|
||||
display_order: number;
|
||||
projects: BackendStandardProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch helper with bounded timeout and safe JSON handling.
|
||||
* Returns data payload or null on any failure (network, timeout, non-2xx, parse error).
|
||||
*/
|
||||
async function apiGet<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as APIResponse<T>;
|
||||
return json?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Home Domain ---
|
||||
|
||||
export async function getResearchProjects(): Promise<ResearchProject[] | null> {
|
||||
const raw = await apiGet<BackendResearchProject[]>("/research-projects");
|
||||
if (!raw) return null;
|
||||
return raw.map((p) => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
abstract: p.abstract,
|
||||
keywords: p.keywords,
|
||||
standardsTrack: p.organization ?? undefined,
|
||||
standardsOrg: p.standards_org ?? undefined,
|
||||
period: p.period ?? undefined,
|
||||
funder: p.funder ?? undefined,
|
||||
deliverables: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getResearchAreaNames(): Promise<string[] | null> {
|
||||
const raw = await apiGet<BackendResearchArea[]>("/research-areas");
|
||||
if (!raw) return null;
|
||||
return raw.map((a) => a.name_en);
|
||||
}
|
||||
|
||||
export async function getStatsSummary(): Promise<{
|
||||
intlPubsCount: number;
|
||||
stdDocsCount: number;
|
||||
patentCount: number;
|
||||
} | null> {
|
||||
const raw = await apiGet<BackendStatsSummary>("/stats/summary");
|
||||
if (!raw) return null;
|
||||
return {
|
||||
intlPubsCount: raw.intl_publications,
|
||||
stdDocsCount: raw.standardization_docs,
|
||||
patentCount: raw.patents,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Members Domain ---
|
||||
|
||||
export async function getMembers(): Promise<Member[] | null> {
|
||||
const raw = await apiGet<BackendMember[]>("/members");
|
||||
if (!raw) return null;
|
||||
return raw.map((m) => ({
|
||||
ko: m.name_ko,
|
||||
en: m.name_en,
|
||||
degree: m.degree as Member["degree"],
|
||||
affiliation: m.affiliation ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAlumni(): Promise<Alumnus[] | null> {
|
||||
const raw = await apiGet<BackendAlumnus[]>("/alumni");
|
||||
if (!raw) return null;
|
||||
return raw.map((a) => ({
|
||||
ko: a.name_ko,
|
||||
en: a.name_en,
|
||||
degree: a.degree as Alumnus["degree"],
|
||||
graduatedAt: a.graduated_at,
|
||||
major: a.major ?? undefined,
|
||||
currentPosition: a.current_position ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Publications & Patents Domain ---
|
||||
|
||||
export async function getPublications(
|
||||
category?: "intl-journal-conf" | "domestic-journal-conf"
|
||||
): Promise<Publication[] | null> {
|
||||
const raw = await apiGet<BackendPublication[]>(
|
||||
category ? `/publications?category=${category}` : "/publications"
|
||||
);
|
||||
if (!raw) return null;
|
||||
return raw.map((p) => ({
|
||||
category: p.category as PubCategory,
|
||||
title: p.title,
|
||||
authors: p.authors ?? undefined,
|
||||
venue: p.venue,
|
||||
volume: p.volume ?? undefined,
|
||||
publishedAt: p.published_at,
|
||||
kci: p.kci,
|
||||
doi: p.doi ?? undefined,
|
||||
isHighlight: p.is_highlight,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getHighlights(): Promise<Publication[] | null> {
|
||||
const raw = await apiGet<BackendPublication[]>("/publications/highlights");
|
||||
if (!raw) return null;
|
||||
return raw.map((p) => ({
|
||||
category: p.category as PubCategory,
|
||||
title: p.title,
|
||||
authors: p.authors ?? undefined,
|
||||
venue: p.venue,
|
||||
volume: p.volume ?? undefined,
|
||||
publishedAt: p.published_at,
|
||||
kci: p.kci,
|
||||
doi: p.doi ?? undefined,
|
||||
isHighlight: p.is_highlight,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPatents(): Promise<Patent[] | null> {
|
||||
const raw = await apiGet<BackendPatent[]>("/patents");
|
||||
if (!raw) return null;
|
||||
return raw.map((p) => ({
|
||||
category: "patent",
|
||||
title: p.title,
|
||||
inventors: p.inventors,
|
||||
applicationNo: p.application_no,
|
||||
applicationAt: p.application_at,
|
||||
registrationNo: p.registration_no ?? undefined,
|
||||
registrationAt: p.registration_at ?? undefined,
|
||||
country: p.country,
|
||||
publishedAt: p.published_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Lectures Domain ---
|
||||
|
||||
export async function getSemesters(): Promise<Semester[] | null> {
|
||||
const raw = await apiGet<BackendSemester[]>("/semesters");
|
||||
if (!raw) return null;
|
||||
return raw.map((s) => ({
|
||||
term: s.term as Semester["term"],
|
||||
year: s.year,
|
||||
courses: s.courses.map((c) => ({
|
||||
code: c.code,
|
||||
en: c.name_en,
|
||||
note: c.note ?? undefined,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Standardization Domain ---
|
||||
|
||||
export async function getStandardsBodies(): Promise<StandardsBody[] | null> {
|
||||
const raw = await apiGet<BackendStandardsBody[]>("/standards-bodies");
|
||||
if (!raw) return null;
|
||||
return raw.map((b) => ({
|
||||
org: b.org,
|
||||
full: b.full_name,
|
||||
scope: b.scope as StandardsBody["scope"],
|
||||
period: b.period,
|
||||
role: b.role ?? undefined,
|
||||
projects: b.projects.map((p) => ({
|
||||
wg: p.wg,
|
||||
name: p.name,
|
||||
documents: p.documents.map((d) => ({
|
||||
title: d.title,
|
||||
ref: d.doc_ref,
|
||||
status: d.status as DocStatus,
|
||||
publishedAt: d.published_at ?? undefined,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
/** @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)
|
||||
},
|
||||
];
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user