백엔드 DB/API + 프론트 3탭 + 인프라 + 홈 연동 전체 구현 계획. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2130 lines
78 KiB
Markdown
2130 lines
78 KiB
Markdown
# Portfolio Service Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** 개인 포트폴리오 서비스 — 프로필/경력/프로젝트/기술스택/자기소개 CRUD + 비밀번호 인증 + PDF 내보내기 + 홈 연동
|
|
|
|
**Architecture:** 새 백엔드 서비스 `portfolio/` (FastAPI + SQLite, 포트 18850) + 프론트 `/portfolio` 페이지 (3탭: 프로필&이력, 프로젝트, 자기소개). 기존 `/api/portfolio`가 stock-lab 주식 포트폴리오로 사용 중이므로 새 서비스 API 경로는 `/api/profile/`로 설정.
|
|
|
|
**Tech Stack:** Python 3.12, FastAPI, SQLite, React, CSS
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Backend (`web-backend/portfolio/`)
|
|
|
|
| 파일 | 역할 |
|
|
|------|------|
|
|
| `portfolio/Dockerfile` | Python 3.12-alpine 기반 컨테이너 |
|
|
| `portfolio/requirements.txt` | fastapi, uvicorn, pydantic |
|
|
| `portfolio/app/__init__.py` | 빈 패키지 파일 |
|
|
| `portfolio/app/main.py` | FastAPI 앱, 라우트, CORS, 인증 미들웨어 |
|
|
| `portfolio/app/db.py` | SQLite 연결, 테이블 초기화, CRUD 함수 |
|
|
| `portfolio/app/models.py` | Pydantic 요청/응답 모델 |
|
|
| `portfolio/app/auth.py` | 비밀번호 검증, 토큰 관리 |
|
|
|
|
### Infra (기존 파일 수정)
|
|
|
|
| 파일 | 변경 |
|
|
|------|------|
|
|
| `docker-compose.yml` | portfolio 서비스 블록 추가 |
|
|
| `nginx/default.conf` | `/api/profile/` 프록시 추가 |
|
|
| `scripts/deploy-nas.sh` | SERVICES에 portfolio 추가 |
|
|
| `scripts/deploy.sh` | BUILD_TARGETS, CONTAINER_NAMES, HEALTH_ENDPOINTS, DATA_DIRS에 추가 |
|
|
|
|
### Frontend (`web-ui/src/`)
|
|
|
|
| 파일 | 역할 |
|
|
|------|------|
|
|
| `pages/portfolio/Portfolio.jsx` | 메인 페이지 (3탭 컨테이너 + 편집 모드) |
|
|
| `pages/portfolio/Portfolio.css` | 전체 스타일 |
|
|
| `pages/portfolio/ProfileTab.jsx` | 탭 1: 프로필 + 경력 타임라인 + 기술스택 |
|
|
| `pages/portfolio/ProjectTab.jsx` | 탭 2: 프로젝트 카드 그리드 + 카테고리 필터 |
|
|
| `pages/portfolio/IntroTab.jsx` | 탭 3: 자기소개 다중 버전 관리 |
|
|
| `pages/portfolio/PasswordModal.jsx` | 비밀번호 입력 모달 |
|
|
| `pages/portfolio/ResumeView.jsx` | PDF 출력 전용 이력서 레이아웃 |
|
|
| `pages/portfolio/usePortfolioApi.js` | API 호출 + 인증 상태 관리 훅 |
|
|
| `routes.jsx` | navLink + appRoute 추가 |
|
|
| `components/Icons.jsx` | IconPortfolio 추가 |
|
|
| `pages/home/Home.jsx` | Profile 섹션을 API 연동 요약 카드로 교체 |
|
|
|
|
---
|
|
|
|
### Task 1: Backend — DB 스키마 + 초기화
|
|
|
|
**Files:**
|
|
- Create: `portfolio/app/__init__.py`
|
|
- Create: `portfolio/app/db.py`
|
|
|
|
- [ ] **Step 1: 빈 패키지 파일 생성**
|
|
|
|
```python
|
|
# portfolio/app/__init__.py
|
|
# (빈 파일)
|
|
```
|
|
|
|
- [ ] **Step 2: db.py 작성 — 연결 헬퍼 + 5개 테이블 초기화**
|
|
|
|
```python
|
|
# portfolio/app/db.py
|
|
import sqlite3
|
|
import json
|
|
import logging
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
logger = logging.getLogger("portfolio")
|
|
|
|
DB_PATH = "/app/data/portfolio.db"
|
|
|
|
|
|
def _conn():
|
|
c = sqlite3.connect(DB_PATH, timeout=10)
|
|
c.row_factory = sqlite3.Row
|
|
c.execute("PRAGMA journal_mode=WAL;")
|
|
c.execute("PRAGMA foreign_keys=ON;")
|
|
return c
|
|
|
|
|
|
def _row_to_dict(r) -> Dict[str, Any]:
|
|
if r is None:
|
|
return None
|
|
d = {c: r[c] for c in r.keys()}
|
|
if "tech_stack" in d and isinstance(d["tech_stack"], str):
|
|
try:
|
|
d["tech_stack"] = json.loads(d["tech_stack"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
d["tech_stack"] = []
|
|
return d
|
|
|
|
|
|
def init_db():
|
|
with _conn() as conn:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS profile (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
name TEXT NOT NULL DEFAULT '',
|
|
name_en TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT '',
|
|
role_en TEXT NOT NULL DEFAULT '',
|
|
email TEXT NOT NULL DEFAULT '',
|
|
phone TEXT NOT NULL DEFAULT '',
|
|
github_url TEXT NOT NULL DEFAULT '',
|
|
blog_url TEXT NOT NULL DEFAULT '',
|
|
photo_url TEXT NOT NULL DEFAULT '',
|
|
bio TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO profile (id) VALUES (1)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS careers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
category TEXT NOT NULL DEFAULT 'company',
|
|
organization TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT '',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
start_date TEXT NOT NULL DEFAULT '',
|
|
end_date TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
category TEXT NOT NULL DEFAULT 'personal',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
tech_stack TEXT NOT NULL DEFAULT '[]',
|
|
role TEXT NOT NULL DEFAULT '',
|
|
start_date TEXT NOT NULL DEFAULT '',
|
|
end_date TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL DEFAULT '',
|
|
image_url TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS skills (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
category TEXT NOT NULL DEFAULT 'language',
|
|
name TEXT NOT NULL DEFAULT '',
|
|
level INTEGER NOT NULL DEFAULT 3,
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS introductions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
is_main INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
""")
|
|
logger.info("portfolio DB initialized")
|
|
```
|
|
|
|
- [ ] **Step 3: db.py — CRUD 함수 추가 (profile)**
|
|
|
|
아래 함수들을 `db.py` 끝에 추가:
|
|
|
|
```python
|
|
# ── Profile ──
|
|
|
|
def get_profile() -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def update_profile(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
fields = {k: v for k, v in data.items() if k != "id" and v is not None}
|
|
if not fields:
|
|
return get_profile()
|
|
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
f"UPDATE profile SET {set_clauses} WHERE id = 1",
|
|
list(fields.values()),
|
|
)
|
|
return get_profile()
|
|
```
|
|
|
|
- [ ] **Step 4: db.py — CRUD 함수 추가 (careers, projects, skills)**
|
|
|
|
```python
|
|
# ── Careers ──
|
|
|
|
def get_careers() -> List[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
rows = conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_career(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO careers (category, organization, role, description, start_date, end_date, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(data.get("category", "company"), data.get("organization", ""),
|
|
data.get("role", ""), data.get("description", ""),
|
|
data.get("start_date", ""), data.get("end_date", ""),
|
|
data.get("sort_order", 0)),
|
|
)
|
|
row = conn.execute("SELECT * FROM careers ORDER BY id DESC LIMIT 1").fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def update_career(career_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
|
|
if not fields:
|
|
return get_career(career_id)
|
|
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
|
with _conn() as conn:
|
|
existing = conn.execute("SELECT id FROM careers WHERE id = ?", (career_id,)).fetchone()
|
|
if not existing:
|
|
return None
|
|
conn.execute(f"UPDATE careers SET {set_clauses} WHERE id = ?", list(fields.values()) + [career_id])
|
|
row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def delete_career(career_id: int) -> bool:
|
|
with _conn() as conn:
|
|
cur = conn.execute("DELETE FROM careers WHERE id = ?", (career_id,))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_career(career_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
# ── Projects ──
|
|
|
|
def get_projects() -> List[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
rows = conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_project(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
tech = json.dumps(data.get("tech_stack", []), ensure_ascii=False)
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO projects (category, title, description, tech_stack, role, start_date, end_date, url, image_url, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(data.get("category", "personal"), data.get("title", ""),
|
|
data.get("description", ""), tech,
|
|
data.get("role", ""), data.get("start_date", ""),
|
|
data.get("end_date", ""), data.get("url", ""),
|
|
data.get("image_url", ""), data.get("sort_order", 0)),
|
|
)
|
|
row = conn.execute("SELECT * FROM projects ORDER BY id DESC LIMIT 1").fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def update_project(project_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
|
|
if "tech_stack" in fields and isinstance(fields["tech_stack"], list):
|
|
fields["tech_stack"] = json.dumps(fields["tech_stack"], ensure_ascii=False)
|
|
if not fields:
|
|
return get_project(project_id)
|
|
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
|
with _conn() as conn:
|
|
existing = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
|
|
if not existing:
|
|
return None
|
|
conn.execute(f"UPDATE projects SET {set_clauses} WHERE id = ?", list(fields.values()) + [project_id])
|
|
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def delete_project(project_id: int) -> bool:
|
|
with _conn() as conn:
|
|
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_project(project_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
# ── Skills ──
|
|
|
|
def get_skills() -> List[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
rows = conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_skill(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO skills (category, name, level, sort_order) VALUES (?, ?, ?, ?)",
|
|
(data.get("category", "language"), data.get("name", ""),
|
|
data.get("level", 3), data.get("sort_order", 0)),
|
|
)
|
|
row = conn.execute("SELECT * FROM skills ORDER BY id DESC LIMIT 1").fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def update_skill(skill_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
fields = {k: v for k, v in data.items() if k != "id" and v is not None}
|
|
if not fields:
|
|
return get_skill(skill_id)
|
|
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
|
with _conn() as conn:
|
|
existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
if not existing:
|
|
return None
|
|
conn.execute(f"UPDATE skills SET {set_clauses} WHERE id = ?", list(fields.values()) + [skill_id])
|
|
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def delete_skill(skill_id: int) -> bool:
|
|
with _conn() as conn:
|
|
cur = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_skill(skill_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
```
|
|
|
|
- [ ] **Step 5: db.py — CRUD 함수 추가 (introductions + public)**
|
|
|
|
```python
|
|
# ── Introductions ──
|
|
|
|
def get_introductions() -> List[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
rows = conn.execute("SELECT * FROM introductions ORDER BY is_main DESC, updated_at DESC").fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_introduction(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO introductions (title, content, is_main) VALUES (?, ?, ?)",
|
|
(data.get("title", ""), data.get("content", ""), data.get("is_main", 0)),
|
|
)
|
|
row = conn.execute("SELECT * FROM introductions ORDER BY id DESC LIMIT 1").fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def update_introduction(intro_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
|
|
if not fields:
|
|
return get_introduction(intro_id)
|
|
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
|
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
|
with _conn() as conn:
|
|
existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
|
if not existing:
|
|
return None
|
|
conn.execute(f"UPDATE introductions SET {set_clauses} WHERE id = ?", list(fields.values()) + [intro_id])
|
|
row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def delete_introduction(intro_id: int) -> bool:
|
|
with _conn() as conn:
|
|
cur = conn.execute("DELETE FROM introductions WHERE id = ?", (intro_id,))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
|
if not existing:
|
|
return None
|
|
conn.execute("UPDATE introductions SET is_main = 0 WHERE is_main = 1")
|
|
conn.execute("UPDATE introductions SET is_main = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (intro_id,))
|
|
row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
# ── Public (일괄 조회) ──
|
|
|
|
def get_public_data() -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
profile = _row_to_dict(conn.execute("SELECT * FROM profile WHERE id = 1").fetchone())
|
|
careers = [_row_to_dict(r) for r in conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()]
|
|
projects = [_row_to_dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()]
|
|
skills = [_row_to_dict(r) for r in conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()]
|
|
main_intro_row = conn.execute("SELECT * FROM introductions WHERE is_main = 1 LIMIT 1").fetchone()
|
|
main_introduction = _row_to_dict(main_intro_row) if main_intro_row else None
|
|
return {
|
|
"profile": profile,
|
|
"careers": careers,
|
|
"projects": projects,
|
|
"skills": skills,
|
|
"main_introduction": main_introduction,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add portfolio/app/__init__.py portfolio/app/db.py
|
|
git commit -m "feat(portfolio): DB 스키마 + CRUD 함수 (5 테이블)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Backend — Pydantic 모델 + 인증
|
|
|
|
**Files:**
|
|
- Create: `portfolio/app/models.py`
|
|
- Create: `portfolio/app/auth.py`
|
|
|
|
- [ ] **Step 1: models.py 작성**
|
|
|
|
```python
|
|
# portfolio/app/models.py
|
|
from typing import Optional, List
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
name_en: Optional[str] = None
|
|
role: Optional[str] = None
|
|
role_en: Optional[str] = None
|
|
email: Optional[str] = None
|
|
phone: Optional[str] = None
|
|
github_url: Optional[str] = None
|
|
blog_url: Optional[str] = None
|
|
photo_url: Optional[str] = None
|
|
bio: Optional[str] = None
|
|
|
|
|
|
class CareerCreate(BaseModel):
|
|
category: str = "company"
|
|
organization: str = ""
|
|
role: str = ""
|
|
description: str = ""
|
|
start_date: str = ""
|
|
end_date: str = ""
|
|
sort_order: int = 0
|
|
|
|
|
|
class CareerUpdate(BaseModel):
|
|
category: Optional[str] = None
|
|
organization: Optional[str] = None
|
|
role: Optional[str] = None
|
|
description: Optional[str] = None
|
|
start_date: Optional[str] = None
|
|
end_date: Optional[str] = None
|
|
sort_order: Optional[int] = None
|
|
|
|
|
|
class ProjectCreate(BaseModel):
|
|
category: str = "personal"
|
|
title: str = ""
|
|
description: str = ""
|
|
tech_stack: List[str] = []
|
|
role: str = ""
|
|
start_date: str = ""
|
|
end_date: str = ""
|
|
url: str = ""
|
|
image_url: str = ""
|
|
sort_order: int = 0
|
|
|
|
|
|
class ProjectUpdate(BaseModel):
|
|
category: Optional[str] = None
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
tech_stack: Optional[List[str]] = None
|
|
role: Optional[str] = None
|
|
start_date: Optional[str] = None
|
|
end_date: Optional[str] = None
|
|
url: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
sort_order: Optional[int] = None
|
|
|
|
|
|
class SkillCreate(BaseModel):
|
|
category: str = "language"
|
|
name: str = ""
|
|
level: int = 3
|
|
sort_order: int = 0
|
|
|
|
|
|
class SkillUpdate(BaseModel):
|
|
category: Optional[str] = None
|
|
name: Optional[str] = None
|
|
level: Optional[int] = None
|
|
sort_order: Optional[int] = None
|
|
|
|
|
|
class IntroCreate(BaseModel):
|
|
title: str = ""
|
|
content: str = ""
|
|
is_main: int = 0
|
|
|
|
|
|
class IntroUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
content: Optional[str] = None
|
|
|
|
|
|
class AuthRequest(BaseModel):
|
|
password: str
|
|
```
|
|
|
|
- [ ] **Step 2: auth.py 작성 — 토큰 관리**
|
|
|
|
```python
|
|
# portfolio/app/auth.py
|
|
import os
|
|
import uuid
|
|
import time
|
|
import logging
|
|
from fastapi import Header, HTTPException
|
|
|
|
logger = logging.getLogger("portfolio")
|
|
|
|
EDIT_PASSWORD = os.getenv("PORTFOLIO_EDIT_PASSWORD", "")
|
|
TOKEN_TTL = 86400 # 24시간
|
|
|
|
_tokens: dict[str, float] = {} # token -> expiry timestamp
|
|
|
|
|
|
def authenticate(password: str) -> dict:
|
|
if not EDIT_PASSWORD:
|
|
raise HTTPException(status_code=503, detail="Edit password not configured")
|
|
if password != EDIT_PASSWORD:
|
|
raise HTTPException(status_code=401, detail="Invalid password")
|
|
token = uuid.uuid4().hex
|
|
_tokens[token] = time.time() + TOKEN_TTL
|
|
_cleanup()
|
|
return {"token": token, "expires_in": TOKEN_TTL}
|
|
|
|
|
|
def require_auth(authorization: str = Header("")):
|
|
token = authorization.replace("Bearer ", "").strip()
|
|
if not token or token not in _tokens:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
if time.time() > _tokens[token]:
|
|
del _tokens[token]
|
|
raise HTTPException(status_code=401, detail="Token expired")
|
|
|
|
|
|
def _cleanup():
|
|
now = time.time()
|
|
expired = [t for t, exp in _tokens.items() if now > exp]
|
|
for t in expired:
|
|
del _tokens[t]
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add portfolio/app/models.py portfolio/app/auth.py
|
|
git commit -m "feat(portfolio): Pydantic 모델 + 토큰 인증"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Backend — FastAPI 앱 + 전체 라우트
|
|
|
|
**Files:**
|
|
- Create: `portfolio/app/main.py`
|
|
|
|
- [ ] **Step 1: main.py 작성**
|
|
|
|
```python
|
|
# portfolio/app/main.py
|
|
import os
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI, Depends, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from .db import (
|
|
init_db, get_public_data,
|
|
get_profile, update_profile,
|
|
get_careers, create_career, update_career, delete_career,
|
|
get_projects, create_project, update_project, delete_project,
|
|
get_skills, create_skill, update_skill, delete_skill,
|
|
get_introductions, create_introduction, update_introduction,
|
|
delete_introduction, set_main_introduction,
|
|
)
|
|
from .models import (
|
|
ProfileUpdate, CareerCreate, CareerUpdate,
|
|
ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate,
|
|
IntroCreate, IntroUpdate, AuthRequest,
|
|
)
|
|
from .auth import authenticate, require_auth
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
|
logger = logging.getLogger("portfolio")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
logger.info("portfolio service 시작")
|
|
yield
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in _cors_origins],
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
allow_headers=["Content-Type", "Authorization"],
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── Public ──
|
|
|
|
@app.get("/api/profile/public")
|
|
def api_public():
|
|
return get_public_data()
|
|
|
|
|
|
# ── Auth ──
|
|
|
|
@app.post("/api/profile/auth")
|
|
def api_auth(body: AuthRequest):
|
|
return authenticate(body.password)
|
|
|
|
|
|
# ── Profile (편집) ──
|
|
|
|
@app.get("/api/profile/profile", dependencies=[Depends(require_auth)])
|
|
def api_profile_get():
|
|
return get_profile()
|
|
|
|
|
|
@app.put("/api/profile/profile", dependencies=[Depends(require_auth)])
|
|
def api_profile_update(body: ProfileUpdate):
|
|
return update_profile(body.model_dump(exclude_none=True))
|
|
|
|
|
|
# ── Careers (편집) ──
|
|
|
|
@app.get("/api/profile/careers", dependencies=[Depends(require_auth)])
|
|
def api_careers_list():
|
|
return get_careers()
|
|
|
|
|
|
@app.post("/api/profile/careers", status_code=201, dependencies=[Depends(require_auth)])
|
|
def api_career_create(body: CareerCreate):
|
|
return create_career(body.model_dump())
|
|
|
|
|
|
@app.put("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
|
|
def api_career_update(career_id: int, body: CareerUpdate):
|
|
result = update_career(career_id, body.model_dump(exclude_none=True))
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Career not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
|
|
def api_career_delete(career_id: int):
|
|
if not delete_career(career_id):
|
|
raise HTTPException(status_code=404, detail="Career not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Projects (편집) ──
|
|
|
|
@app.get("/api/profile/projects", dependencies=[Depends(require_auth)])
|
|
def api_projects_list():
|
|
return get_projects()
|
|
|
|
|
|
@app.post("/api/profile/projects", status_code=201, dependencies=[Depends(require_auth)])
|
|
def api_project_create(body: ProjectCreate):
|
|
return create_project(body.model_dump())
|
|
|
|
|
|
@app.put("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
|
|
def api_project_update(project_id: int, body: ProjectUpdate):
|
|
result = update_project(project_id, body.model_dump(exclude_none=True))
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
|
|
def api_project_delete(project_id: int):
|
|
if not delete_project(project_id):
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Skills (편집) ──
|
|
|
|
@app.get("/api/profile/skills", dependencies=[Depends(require_auth)])
|
|
def api_skills_list():
|
|
return get_skills()
|
|
|
|
|
|
@app.post("/api/profile/skills", status_code=201, dependencies=[Depends(require_auth)])
|
|
def api_skill_create(body: SkillCreate):
|
|
return create_skill(body.model_dump())
|
|
|
|
|
|
@app.put("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
|
|
def api_skill_update(skill_id: int, body: SkillUpdate):
|
|
result = update_skill(skill_id, body.model_dump(exclude_none=True))
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
|
|
def api_skill_delete(skill_id: int):
|
|
if not delete_skill(skill_id):
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Introductions (편집) ──
|
|
|
|
@app.get("/api/profile/introductions", dependencies=[Depends(require_auth)])
|
|
def api_intros_list():
|
|
return get_introductions()
|
|
|
|
|
|
@app.post("/api/profile/introductions", status_code=201, dependencies=[Depends(require_auth)])
|
|
def api_intro_create(body: IntroCreate):
|
|
return create_introduction(body.model_dump())
|
|
|
|
|
|
@app.put("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
|
|
def api_intro_update(intro_id: int, body: IntroUpdate):
|
|
result = update_introduction(intro_id, body.model_dump(exclude_none=True))
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Introduction not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
|
|
def api_intro_delete(intro_id: int):
|
|
if not delete_introduction(intro_id):
|
|
raise HTTPException(status_code=404, detail="Introduction not found")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.patch("/api/profile/introductions/{intro_id}/main", dependencies=[Depends(require_auth)])
|
|
def api_intro_set_main(intro_id: int):
|
|
result = set_main_introduction(intro_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Introduction not found")
|
|
return result
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add portfolio/app/main.py
|
|
git commit -m "feat(portfolio): FastAPI 앱 + 전체 API 라우트"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Backend — Dockerfile + requirements.txt
|
|
|
|
**Files:**
|
|
- Create: `portfolio/Dockerfile`
|
|
- Create: `portfolio/requirements.txt`
|
|
|
|
- [ ] **Step 1: Dockerfile 작성**
|
|
|
|
```dockerfile
|
|
FROM python:3.12-alpine
|
|
ENV PYTHONUNBUFFERED=1
|
|
|
|
WORKDIR /app
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
```
|
|
|
|
- [ ] **Step 2: requirements.txt 작성**
|
|
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.30.6
|
|
pydantic>=2.0
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add portfolio/Dockerfile portfolio/requirements.txt
|
|
git commit -m "feat(portfolio): Dockerfile + requirements"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Infra — Docker Compose + Nginx + 배포 스크립트
|
|
|
|
**Files:**
|
|
- Modify: `docker-compose.yml` (agent-office 블록 뒤에 삽입)
|
|
- Modify: `nginx/default.conf` (portfolio 프록시를 `/api/profile/`로 추가)
|
|
- Modify: `scripts/deploy-nas.sh:5`
|
|
- Modify: `scripts/deploy.sh:10,12,14,16`
|
|
|
|
- [ ] **Step 1: docker-compose.yml에 portfolio 서비스 추가**
|
|
|
|
agent-office 서비스 블록(`healthcheck` 3줄 포함) 뒤, `travel-proxy:` 블록 앞에 삽입:
|
|
|
|
```yaml
|
|
portfolio:
|
|
build:
|
|
context: ./portfolio
|
|
container_name: portfolio
|
|
restart: unless-stopped
|
|
ports:
|
|
- "18850:8000"
|
|
environment:
|
|
- TZ=${TZ:-Asia/Seoul}
|
|
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
|
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
|
volumes:
|
|
- ${RUNTIME_PATH:-.}/data/portfolio:/app/data
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
```
|
|
|
|
- [ ] **Step 2: nginx/default.conf에 `/api/profile/` 프록시 추가**
|
|
|
|
기존 `/api/portfolio` (stock-lab) 블록 뒤, `# agent-office` 블록 앞에 삽입:
|
|
|
|
```nginx
|
|
# profile API (Portfolio Service)
|
|
location /api/profile/ {
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_pass http://portfolio:8000/api/profile/;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: deploy-nas.sh SERVICES에 portfolio 추가**
|
|
|
|
Line 5를 수정:
|
|
|
|
```bash
|
|
SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office portfolio nginx scripts"
|
|
```
|
|
|
|
- [ ] **Step 4: deploy.sh에 portfolio 추가 (4곳)**
|
|
|
|
Line 10:
|
|
```bash
|
|
BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend"
|
|
```
|
|
|
|
Line 12:
|
|
```bash
|
|
CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy portfolio lotto-frontend"
|
|
```
|
|
|
|
Line 14:
|
|
```bash
|
|
HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio"
|
|
```
|
|
|
|
Line 16:
|
|
```bash
|
|
DATA_DIRS="music stock blog realestate agent-office portfolio"
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add docker-compose.yml nginx/default.conf scripts/deploy-nas.sh scripts/deploy.sh
|
|
git commit -m "infra(portfolio): Docker Compose + Nginx + 배포 스크립트"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Frontend — 라우팅 + 아이콘 + API 훅
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/routes.jsx`
|
|
- Modify: `web-ui/src/components/Icons.jsx`
|
|
- Create: `web-ui/src/pages/portfolio/usePortfolioApi.js`
|
|
|
|
- [ ] **Step 1: Icons.jsx에 IconPortfolio 추가**
|
|
|
|
파일 끝 `export const IconBuilding` 뒤에 추가:
|
|
|
|
```jsx
|
|
export const IconPortfolio = () =>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>;
|
|
```
|
|
|
|
- [ ] **Step 2: routes.jsx에 Portfolio navLink + route 추가**
|
|
|
|
import 섹션에 추가:
|
|
```jsx
|
|
import { IconPortfolio } from './components/Icons';
|
|
```
|
|
|
|
lazy import 추가 (기존 lazy import 블록 끝에):
|
|
```jsx
|
|
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
|
|
```
|
|
|
|
navLinks 배열에서 `agent-office` 항목 앞에 추가:
|
|
```jsx
|
|
{
|
|
id: 'portfolio',
|
|
label: 'Portfolio',
|
|
path: '/portfolio',
|
|
subtitle: 'RESUME',
|
|
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
|
|
icon: <IconPortfolio />,
|
|
accent: '#06b6d4',
|
|
},
|
|
```
|
|
|
|
appRoutes 배열에서 `agent-office` 항목 앞에 추가:
|
|
```jsx
|
|
{
|
|
path: 'portfolio',
|
|
element: <Portfolio />,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 3: usePortfolioApi.js 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/usePortfolioApi.js
|
|
import { useState, useCallback } from 'react';
|
|
|
|
const BASE = '/api/profile';
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
headers: { 'Content-Type': 'application/json', ...options.headers },
|
|
...options,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
|
throw new Error(err.detail || res.statusText);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export default function usePortfolioApi() {
|
|
const [token, setToken] = useState(null);
|
|
const [authError, setAuthError] = useState('');
|
|
|
|
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
|
|
|
const login = useCallback(async (password) => {
|
|
setAuthError('');
|
|
try {
|
|
const data = await apiFetch('/auth', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
setToken(data.token);
|
|
return true;
|
|
} catch (err) {
|
|
setAuthError(err.message);
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
const logout = useCallback(() => setToken(null), []);
|
|
|
|
// ── Public ──
|
|
const fetchPublic = useCallback(() => apiFetch('/public'), []);
|
|
|
|
// ── Profile ──
|
|
const fetchProfile = useCallback(() =>
|
|
apiFetch('/profile', { headers: authHeaders }), [token]);
|
|
const saveProfile = useCallback((data) =>
|
|
apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
|
|
// ── Careers ──
|
|
const fetchCareers = useCallback(() =>
|
|
apiFetch('/careers', { headers: authHeaders }), [token]);
|
|
const addCareer = useCallback((data) =>
|
|
apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const editCareer = useCallback((id, data) =>
|
|
apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const removeCareer = useCallback((id) =>
|
|
apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
|
|
|
// ── Projects ──
|
|
const fetchProjects = useCallback(() =>
|
|
apiFetch('/projects', { headers: authHeaders }), [token]);
|
|
const addProject = useCallback((data) =>
|
|
apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const editProject = useCallback((id, data) =>
|
|
apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const removeProject = useCallback((id) =>
|
|
apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
|
|
|
// ── Skills ──
|
|
const fetchSkills = useCallback(() =>
|
|
apiFetch('/skills', { headers: authHeaders }), [token]);
|
|
const addSkill = useCallback((data) =>
|
|
apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const editSkill = useCallback((id, data) =>
|
|
apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const removeSkill = useCallback((id) =>
|
|
apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
|
|
|
// ── Introductions ──
|
|
const fetchIntros = useCallback(() =>
|
|
apiFetch('/introductions', { headers: authHeaders }), [token]);
|
|
const addIntro = useCallback((data) =>
|
|
apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const editIntro = useCallback((id, data) =>
|
|
apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
|
const removeIntro = useCallback((id) =>
|
|
apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
|
const setMainIntro = useCallback((id) =>
|
|
apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]);
|
|
|
|
return {
|
|
token, authError, login, logout,
|
|
fetchPublic,
|
|
fetchProfile, saveProfile,
|
|
fetchCareers, addCareer, editCareer, removeCareer,
|
|
fetchProjects, addProject, editProject, removeProject,
|
|
fetchSkills, addSkill, editSkill, removeSkill,
|
|
fetchIntros, addIntro, editIntro, removeIntro, setMainIntro,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/routes.jsx src/components/Icons.jsx src/pages/portfolio/usePortfolioApi.js
|
|
git commit -m "feat(portfolio): 라우팅 + 아이콘 + API 훅"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Frontend — PasswordModal 컴포넌트
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/portfolio/PasswordModal.jsx`
|
|
|
|
- [ ] **Step 1: PasswordModal.jsx 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/PasswordModal.jsx
|
|
import { useState } from 'react';
|
|
|
|
export default function PasswordModal({ open, onAuth, onClose, error }) {
|
|
const [pw, setPw] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
if (!open) return null;
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!pw.trim()) return;
|
|
setLoading(true);
|
|
await onAuth(pw);
|
|
setLoading(false);
|
|
setPw('');
|
|
};
|
|
|
|
return (
|
|
<div className="pf-modal-backdrop" onClick={onClose}>
|
|
<div className="pf-modal" onClick={(e) => e.stopPropagation()}>
|
|
<h3 className="pf-modal__title">편집 모드</h3>
|
|
<p className="pf-modal__desc">편집하려면 비밀번호를 입력하세요.</p>
|
|
<form onSubmit={handleSubmit}>
|
|
<input
|
|
type="password"
|
|
className="pf-modal__input"
|
|
placeholder="비밀번호"
|
|
value={pw}
|
|
onChange={(e) => setPw(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
{error && <p className="pf-modal__error">{error}</p>}
|
|
<div className="pf-modal__actions">
|
|
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
|
<button type="submit" className="button primary" disabled={loading || !pw.trim()}>
|
|
{loading ? '확인 중...' : '확인'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/PasswordModal.jsx
|
|
git commit -m "feat(portfolio): 비밀번호 모달 컴포넌트"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Frontend — ProfileTab (탭 1: 프로필 + 경력 + 기술)
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/portfolio/ProfileTab.jsx`
|
|
|
|
- [ ] **Step 1: ProfileTab.jsx 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/ProfileTab.jsx
|
|
import { useState } from 'react';
|
|
|
|
const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
|
|
const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
|
|
|
|
const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
|
|
const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
|
|
|
|
export default function ProfileTab({ data, editing, api, onRefresh }) {
|
|
const { profile, careers, skills } = data;
|
|
const [editingProfile, setEditingProfile] = useState(null);
|
|
const [careerForm, setCareerForm] = useState(null);
|
|
const [skillForm, setSkillForm] = useState(null);
|
|
|
|
// ── Profile 편집 ──
|
|
const startEditProfile = () => setEditingProfile({ ...profile });
|
|
const saveProfileEdit = async () => {
|
|
await api.saveProfile(editingProfile);
|
|
setEditingProfile(null);
|
|
onRefresh();
|
|
};
|
|
|
|
// ── Career CRUD ──
|
|
const saveCareer = async () => {
|
|
if (careerForm.id) {
|
|
await api.editCareer(careerForm.id, careerForm);
|
|
} else {
|
|
await api.addCareer(careerForm);
|
|
}
|
|
setCareerForm(null);
|
|
onRefresh();
|
|
};
|
|
const deleteCareer = async (id) => {
|
|
await api.removeCareer(id);
|
|
onRefresh();
|
|
};
|
|
|
|
// ── Skill CRUD ──
|
|
const saveSkill = async () => {
|
|
if (skillForm.id) {
|
|
await api.editSkill(skillForm.id, skillForm);
|
|
} else {
|
|
await api.addSkill(skillForm);
|
|
}
|
|
setSkillForm(null);
|
|
onRefresh();
|
|
};
|
|
const deleteSkill = async (id) => {
|
|
await api.removeSkill(id);
|
|
onRefresh();
|
|
};
|
|
|
|
const grouped = (items, catMap) => {
|
|
const groups = {};
|
|
for (const key of Object.keys(catMap)) groups[key] = [];
|
|
for (const item of items) {
|
|
const cat = item.category || Object.keys(catMap)[0];
|
|
if (!groups[cat]) groups[cat] = [];
|
|
groups[cat].push(item);
|
|
}
|
|
return groups;
|
|
};
|
|
|
|
return (
|
|
<div className="pf-profile-tab">
|
|
{/* ── 프로필 카드 ── */}
|
|
<div className="pf-profile-card">
|
|
{editingProfile ? (
|
|
<div className="pf-edit-form">
|
|
<label>이름 <input value={editingProfile.name} onChange={(e) => setEditingProfile(p => ({...p, name: e.target.value}))} /></label>
|
|
<label>이름(영문) <input value={editingProfile.name_en} onChange={(e) => setEditingProfile(p => ({...p, name_en: e.target.value}))} /></label>
|
|
<label>직함 <input value={editingProfile.role} onChange={(e) => setEditingProfile(p => ({...p, role: e.target.value}))} /></label>
|
|
<label>직함(영문) <input value={editingProfile.role_en} onChange={(e) => setEditingProfile(p => ({...p, role_en: e.target.value}))} /></label>
|
|
<label>이메일 <input value={editingProfile.email} onChange={(e) => setEditingProfile(p => ({...p, email: e.target.value}))} /></label>
|
|
<label>전화번호 <input value={editingProfile.phone} onChange={(e) => setEditingProfile(p => ({...p, phone: e.target.value}))} /></label>
|
|
<label>GitHub <input value={editingProfile.github_url} onChange={(e) => setEditingProfile(p => ({...p, github_url: e.target.value}))} /></label>
|
|
<label>블로그 <input value={editingProfile.blog_url} onChange={(e) => setEditingProfile(p => ({...p, blog_url: e.target.value}))} /></label>
|
|
<label>사진 URL <input value={editingProfile.photo_url} onChange={(e) => setEditingProfile(p => ({...p, photo_url: e.target.value}))} /></label>
|
|
<label>소개 <textarea value={editingProfile.bio} rows={3} onChange={(e) => setEditingProfile(p => ({...p, bio: e.target.value}))} /></label>
|
|
<div className="pf-edit-form__actions">
|
|
<button className="button ghost" onClick={() => setEditingProfile(null)}>취소</button>
|
|
<button className="button primary" onClick={saveProfileEdit}>저장</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="pf-profile-card__header">
|
|
{profile.photo_url && <img className="pf-profile-card__photo" src={profile.photo_url} alt="" />}
|
|
<div>
|
|
<h2 className="pf-profile-card__name">{profile.name || '이름 미설정'}</h2>
|
|
{profile.name_en && <p className="pf-profile-card__name-en">{profile.name_en}</p>}
|
|
<p className="pf-profile-card__role">{profile.role || profile.role_en}</p>
|
|
</div>
|
|
</div>
|
|
{profile.bio && <p className="pf-profile-card__bio">{profile.bio}</p>}
|
|
<div className="pf-profile-card__links">
|
|
{profile.email && <a href={`mailto:${profile.email}`}>{profile.email}</a>}
|
|
{profile.github_url && <a href={profile.github_url} target="_blank" rel="noreferrer">GitHub</a>}
|
|
{profile.blog_url && <a href={profile.blog_url} target="_blank" rel="noreferrer">Blog</a>}
|
|
</div>
|
|
{editing && <button className="button ghost pf-edit-btn" onClick={startEditProfile}>프로필 수정</button>}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 경력 타임라인 ── */}
|
|
<div className="pf-section">
|
|
<div className="pf-section__header">
|
|
<h3>경력</h3>
|
|
{editing && <button className="button ghost" onClick={() => setCareerForm({...emptyCareer})}>+ 추가</button>}
|
|
</div>
|
|
{careerForm && (
|
|
<div className="pf-edit-form">
|
|
<label>구분
|
|
<select value={careerForm.category} onChange={(e) => setCareerForm(f => ({...f, category: e.target.value}))}>
|
|
{Object.entries(CAREER_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>기관명 <input value={careerForm.organization} onChange={(e) => setCareerForm(f => ({...f, organization: e.target.value}))} /></label>
|
|
<label>직함 <input value={careerForm.role} onChange={(e) => setCareerForm(f => ({...f, role: e.target.value}))} /></label>
|
|
<label>설명 <textarea value={careerForm.description} rows={2} onChange={(e) => setCareerForm(f => ({...f, description: e.target.value}))} /></label>
|
|
<div className="pf-edit-form__row">
|
|
<label>시작 <input type="month" value={careerForm.start_date} onChange={(e) => setCareerForm(f => ({...f, start_date: e.target.value}))} /></label>
|
|
<label>종료 <input type="month" value={careerForm.end_date} onChange={(e) => setCareerForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
|
|
</div>
|
|
<div className="pf-edit-form__actions">
|
|
<button className="button ghost" onClick={() => setCareerForm(null)}>취소</button>
|
|
<button className="button primary" onClick={saveCareer}>저장</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
|
|
items.length > 0 && (
|
|
<div key={cat} className="pf-career-group">
|
|
<h4 className="pf-career-group__title">{CAREER_CATEGORIES[cat]}</h4>
|
|
{items.map((c) => (
|
|
<div key={c.id} className="pf-career-item">
|
|
<span className="pf-career-item__period">{c.start_date} — {c.end_date || '현재'}</span>
|
|
<strong className="pf-career-item__role">{c.role}</strong>
|
|
<span className="pf-career-item__org">{c.organization}</span>
|
|
{c.description && <p className="pf-career-item__desc">{c.description}</p>}
|
|
{editing && (
|
|
<div className="pf-career-item__actions">
|
|
<button className="button ghost" onClick={() => setCareerForm({...c})}>수정</button>
|
|
<button className="button ghost" onClick={() => deleteCareer(c.id)}>삭제</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 기술 스택 ── */}
|
|
<div className="pf-section">
|
|
<div className="pf-section__header">
|
|
<h3>기술 스택</h3>
|
|
{editing && <button className="button ghost" onClick={() => setSkillForm({...emptySkill})}>+ 추가</button>}
|
|
</div>
|
|
{skillForm && (
|
|
<div className="pf-edit-form">
|
|
<label>구분
|
|
<select value={skillForm.category} onChange={(e) => setSkillForm(f => ({...f, category: e.target.value}))}>
|
|
{Object.entries(SKILL_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>기술명 <input value={skillForm.name} onChange={(e) => setSkillForm(f => ({...f, name: e.target.value}))} /></label>
|
|
<label>숙련도 (1~5)
|
|
<input type="range" min={1} max={5} value={skillForm.level} onChange={(e) => setSkillForm(f => ({...f, level: +e.target.value}))} />
|
|
<span>{skillForm.level}</span>
|
|
</label>
|
|
<div className="pf-edit-form__actions">
|
|
<button className="button ghost" onClick={() => setSkillForm(null)}>취소</button>
|
|
<button className="button primary" onClick={saveSkill}>저장</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
|
|
items.length > 0 && (
|
|
<div key={cat} className="pf-skill-group">
|
|
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
|
|
<div className="pf-skill-group__tags">
|
|
{items.map((s) => (
|
|
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
|
{s.name}
|
|
{editing && (
|
|
<span className="pf-skill-tag__actions">
|
|
<button onClick={() => setSkillForm({...s})}>✎</button>
|
|
<button onClick={() => deleteSkill(s.id)}>×</button>
|
|
</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/ProfileTab.jsx
|
|
git commit -m "feat(portfolio): ProfileTab — 프로필 + 경력 + 기술 탭"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Frontend — ProjectTab (탭 2: 프로젝트)
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/portfolio/ProjectTab.jsx`
|
|
|
|
- [ ] **Step 1: ProjectTab.jsx 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/ProjectTab.jsx
|
|
import { useState } from 'react';
|
|
|
|
const CATEGORIES = [
|
|
{ key: 'all', label: '전체' },
|
|
{ key: 'company', label: '회사' },
|
|
{ key: 'personal', label: '개인' },
|
|
{ key: 'academy', label: '아카데미' },
|
|
];
|
|
|
|
const emptyProject = {
|
|
category: 'personal', title: '', description: '', tech_stack: [],
|
|
role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
|
|
};
|
|
|
|
export default function ProjectTab({ projects, editing, api, onRefresh }) {
|
|
const [filter, setFilter] = useState('all');
|
|
const [form, setForm] = useState(null);
|
|
const [techInput, setTechInput] = useState('');
|
|
|
|
const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);
|
|
|
|
const addTech = () => {
|
|
const tag = techInput.trim();
|
|
if (tag && !form.tech_stack.includes(tag)) {
|
|
setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
|
|
}
|
|
setTechInput('');
|
|
};
|
|
|
|
const removeTech = (tag) => {
|
|
setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
|
|
};
|
|
|
|
const save = async () => {
|
|
if (form.id) {
|
|
await api.editProject(form.id, form);
|
|
} else {
|
|
await api.addProject(form);
|
|
}
|
|
setForm(null);
|
|
setTechInput('');
|
|
onRefresh();
|
|
};
|
|
|
|
const remove = async (id) => {
|
|
await api.removeProject(id);
|
|
onRefresh();
|
|
};
|
|
|
|
return (
|
|
<div className="pf-project-tab">
|
|
{/* 카테고리 필터 */}
|
|
<div className="pf-filter-bar">
|
|
{CATEGORIES.map(c => (
|
|
<button
|
|
key={c.key}
|
|
className={`pf-filter-btn${filter === c.key ? ' is-active' : ''}`}
|
|
onClick={() => setFilter(c.key)}
|
|
>
|
|
{c.label}
|
|
</button>
|
|
))}
|
|
{editing && <button className="button ghost" onClick={() => { setForm({...emptyProject}); setTechInput(''); }}>+ 추가</button>}
|
|
</div>
|
|
|
|
{/* 추가/수정 폼 */}
|
|
{form && (
|
|
<div className="pf-edit-form">
|
|
<label>구분
|
|
<select value={form.category} onChange={(e) => setForm(f => ({...f, category: e.target.value}))}>
|
|
{CATEGORIES.filter(c => c.key !== 'all').map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>프로젝트명 <input value={form.title} onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
|
|
<label>설명 <textarea value={form.description} rows={3} onChange={(e) => setForm(f => ({...f, description: e.target.value}))} /></label>
|
|
<label>담당 역할 <input value={form.role} onChange={(e) => setForm(f => ({...f, role: e.target.value}))} /></label>
|
|
<div className="pf-edit-form__row">
|
|
<label>시작 <input type="month" value={form.start_date} onChange={(e) => setForm(f => ({...f, start_date: e.target.value}))} /></label>
|
|
<label>종료 <input type="month" value={form.end_date} onChange={(e) => setForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
|
|
</div>
|
|
<label>URL <input value={form.url} onChange={(e) => setForm(f => ({...f, url: e.target.value}))} /></label>
|
|
<label>기술 스택
|
|
<div className="pf-tech-input">
|
|
<input
|
|
value={techInput}
|
|
onChange={(e) => setTechInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTech(); } }}
|
|
placeholder="기술명 입력 후 Enter"
|
|
/>
|
|
<div className="pf-tech-tags">
|
|
{form.tech_stack.map(t => (
|
|
<span key={t} className="pf-tech-tag">{t} <button onClick={() => removeTech(t)}>×</button></span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
<div className="pf-edit-form__actions">
|
|
<button className="button ghost" onClick={() => { setForm(null); setTechInput(''); }}>취소</button>
|
|
<button className="button primary" onClick={save}>저장</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 프로젝트 카드 그리드 */}
|
|
<div className="pf-project-grid">
|
|
{filtered.length === 0 && <p className="pf-empty">프로젝트가 없습니다.</p>}
|
|
{filtered.map(p => (
|
|
<div key={p.id} className="pf-project-card">
|
|
<div className="pf-project-card__header">
|
|
<span className={`pf-project-card__cat pf-project-card__cat--${p.category}`}>{CATEGORIES.find(c => c.key === p.category)?.label}</span>
|
|
<span className="pf-project-card__period">{p.start_date} — {p.end_date || '현재'}</span>
|
|
</div>
|
|
<h4 className="pf-project-card__title">{p.title}</h4>
|
|
{p.role && <p className="pf-project-card__role">{p.role}</p>}
|
|
{p.description && <p className="pf-project-card__desc">{p.description}</p>}
|
|
{p.tech_stack?.length > 0 && (
|
|
<div className="pf-project-card__tags">
|
|
{p.tech_stack.map(t => <span key={t} className="pf-tech-tag">{t}</span>)}
|
|
</div>
|
|
)}
|
|
{p.url && <a className="pf-project-card__link" href={p.url} target="_blank" rel="noreferrer">링크 →</a>}
|
|
{editing && (
|
|
<div className="pf-project-card__actions">
|
|
<button className="button ghost" onClick={() => { setForm({...p}); setTechInput(''); }}>수정</button>
|
|
<button className="button ghost" onClick={() => remove(p.id)}>삭제</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/ProjectTab.jsx
|
|
git commit -m "feat(portfolio): ProjectTab — 프로젝트 카드 그리드 + 필터"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Frontend — IntroTab (탭 3: 자기소개 관리)
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/portfolio/IntroTab.jsx`
|
|
|
|
- [ ] **Step 1: IntroTab.jsx 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/IntroTab.jsx
|
|
import { useState } from 'react';
|
|
|
|
const emptyIntro = { title: '', content: '', is_main: 0 };
|
|
|
|
export default function IntroTab({ introductions, editing, api, onRefresh }) {
|
|
const [form, setForm] = useState(null);
|
|
const [copiedId, setCopiedId] = useState(null);
|
|
|
|
const save = async () => {
|
|
if (form.id) {
|
|
await api.editIntro(form.id, { title: form.title, content: form.content });
|
|
} else {
|
|
await api.addIntro(form);
|
|
}
|
|
setForm(null);
|
|
onRefresh();
|
|
};
|
|
|
|
const remove = async (id) => {
|
|
await api.removeIntro(id);
|
|
onRefresh();
|
|
};
|
|
|
|
const setMain = async (id) => {
|
|
await api.setMainIntro(id);
|
|
onRefresh();
|
|
};
|
|
|
|
const copyToClipboard = async (intro) => {
|
|
try {
|
|
await navigator.clipboard.writeText(intro.content);
|
|
setCopiedId(intro.id);
|
|
setTimeout(() => setCopiedId(null), 1500);
|
|
} catch {
|
|
/* 무시 */
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="pf-intro-tab">
|
|
{editing && (
|
|
<div className="pf-intro-tab__toolbar">
|
|
<button className="button primary" onClick={() => setForm({...emptyIntro})}>+ 새 글 작성</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 작성/수정 폼 */}
|
|
{form && (
|
|
<div className="pf-edit-form">
|
|
<label>버전명 <input value={form.title} placeholder="예: 이직용 짧은 버전" onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
|
|
<label>본문 <textarea value={form.content} rows={8} onChange={(e) => setForm(f => ({...f, content: e.target.value}))} /></label>
|
|
<div className="pf-edit-form__actions">
|
|
<button className="button ghost" onClick={() => setForm(null)}>취소</button>
|
|
<button className="button primary" onClick={save}>저장</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 자기소개 목록 */}
|
|
<div className="pf-intro-list">
|
|
{introductions.length === 0 && <p className="pf-empty">자기소개 글이 없습니다.</p>}
|
|
{introductions.map(intro => (
|
|
<div key={intro.id} className={`pf-intro-card${intro.is_main ? ' is-main' : ''}`}>
|
|
<div className="pf-intro-card__header">
|
|
<span className="pf-intro-card__title">
|
|
{intro.is_main && <span className="pf-intro-card__badge">MAIN</span>}
|
|
{intro.title || '제목 없음'}
|
|
</span>
|
|
<span className="pf-intro-card__date">
|
|
{intro.updated_at ? new Date(intro.updated_at).toLocaleDateString('ko-KR') : ''}
|
|
</span>
|
|
</div>
|
|
<p className="pf-intro-card__preview">{intro.content}</p>
|
|
<div className="pf-intro-card__actions">
|
|
<button
|
|
className="button ghost"
|
|
onClick={() => copyToClipboard(intro)}
|
|
>
|
|
{copiedId === intro.id ? '복사됨!' : '복사'}
|
|
</button>
|
|
{editing && (
|
|
<>
|
|
<button className="button ghost" onClick={() => setForm({...intro})}>수정</button>
|
|
{!intro.is_main && <button className="button ghost" onClick={() => setMain(intro.id)}>메인 지정</button>}
|
|
<button className="button ghost" onClick={() => remove(intro.id)}>삭제</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/IntroTab.jsx
|
|
git commit -m "feat(portfolio): IntroTab — 자기소개 다중 버전 + 클립보드 복사"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Frontend — ResumeView (PDF 인쇄 전용)
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/portfolio/ResumeView.jsx`
|
|
|
|
- [ ] **Step 1: ResumeView.jsx 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/ResumeView.jsx
|
|
|
|
export default function ResumeView({ data, onClose }) {
|
|
const { profile, careers, projects, skills, main_introduction } = data;
|
|
|
|
const handlePrint = () => {
|
|
window.print();
|
|
};
|
|
|
|
return (
|
|
<div className="pf-resume-overlay">
|
|
<div className="pf-resume-actions no-print">
|
|
<button className="button primary" onClick={handlePrint}>PDF 저장 / 인쇄</button>
|
|
<button className="button ghost" onClick={onClose}>닫기</button>
|
|
</div>
|
|
<div className="pf-resume">
|
|
{/* 헤더 */}
|
|
<header className="pf-resume__header">
|
|
<div>
|
|
<h1 className="pf-resume__name">{profile.name}</h1>
|
|
<p className="pf-resume__role">{profile.role}</p>
|
|
</div>
|
|
<div className="pf-resume__contact">
|
|
{profile.email && <span>{profile.email}</span>}
|
|
{profile.phone && <span>{profile.phone}</span>}
|
|
{profile.github_url && <span>{profile.github_url}</span>}
|
|
</div>
|
|
</header>
|
|
|
|
{/* About */}
|
|
{(main_introduction?.content || profile.bio) && (
|
|
<section className="pf-resume__section">
|
|
<h2>About</h2>
|
|
<p>{main_introduction?.content || profile.bio}</p>
|
|
</section>
|
|
)}
|
|
|
|
{/* Experience */}
|
|
{careers.length > 0 && (
|
|
<section className="pf-resume__section">
|
|
<h2>Experience</h2>
|
|
{careers.map(c => (
|
|
<div key={c.id} className="pf-resume__item">
|
|
<div className="pf-resume__item-header">
|
|
<strong>{c.role}</strong>
|
|
<span>{c.organization}</span>
|
|
<span className="pf-resume__period">{c.start_date} — {c.end_date || '현재'}</span>
|
|
</div>
|
|
{c.description && <p>{c.description}</p>}
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{/* Projects */}
|
|
{projects.length > 0 && (
|
|
<section className="pf-resume__section">
|
|
<h2>Projects</h2>
|
|
{projects.map(p => (
|
|
<div key={p.id} className="pf-resume__item">
|
|
<div className="pf-resume__item-header">
|
|
<strong>{p.title}</strong>
|
|
<span className="pf-resume__period">{p.start_date} — {p.end_date || '현재'}</span>
|
|
</div>
|
|
{p.description && <p>{p.description}</p>}
|
|
{p.tech_stack?.length > 0 && (
|
|
<p className="pf-resume__tech">{p.tech_stack.join(' · ')}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{/* Skills */}
|
|
{skills.length > 0 && (
|
|
<section className="pf-resume__section">
|
|
<h2>Skills</h2>
|
|
<p className="pf-resume__skills">{skills.map(s => s.name).join(' · ')}</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/ResumeView.jsx
|
|
git commit -m "feat(portfolio): ResumeView — PDF 인쇄 전용 이력서 레이아웃"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Frontend — Portfolio 메인 페이지 + CSS
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/portfolio/Portfolio.jsx`
|
|
- Create: `web-ui/src/pages/portfolio/Portfolio.css`
|
|
|
|
- [ ] **Step 1: Portfolio.jsx 작성**
|
|
|
|
```jsx
|
|
// web-ui/src/pages/portfolio/Portfolio.jsx
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
|
import SwipeableView from '../../components/SwipeableView';
|
|
import usePortfolioApi from './usePortfolioApi';
|
|
import PasswordModal from './PasswordModal';
|
|
import ProfileTab from './ProfileTab';
|
|
import ProjectTab from './ProjectTab';
|
|
import IntroTab from './IntroTab';
|
|
import ResumeView from './ResumeView';
|
|
import './Portfolio.css';
|
|
|
|
export default function Portfolio() {
|
|
const isMobile = useIsMobile();
|
|
const api = usePortfolioApi();
|
|
|
|
const [data, setData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [editing, setEditing] = useState(false);
|
|
const [showPwModal, setShowPwModal] = useState(false);
|
|
const [showResume, setShowResume] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const d = await api.fetchPublic();
|
|
setData(d);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const handleEditToggle = () => {
|
|
if (editing) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
if (api.token) {
|
|
setEditing(true);
|
|
} else {
|
|
setShowPwModal(true);
|
|
}
|
|
};
|
|
|
|
const handleAuth = async (pw) => {
|
|
const ok = await api.login(pw);
|
|
if (ok) {
|
|
setShowPwModal(false);
|
|
setEditing(true);
|
|
}
|
|
};
|
|
|
|
// 편집 후 데이터 리프레시
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const d = await api.fetchPublic();
|
|
setData(d);
|
|
} catch { /* 무시 */ }
|
|
}, []);
|
|
|
|
if (loading && !data) return <div className="pf-page"><p className="pf-loading">불러오는 중...</p></div>;
|
|
if (error && !data) return <div className="pf-page"><p className="pf-error">{error}</p></div>;
|
|
if (!data) return null;
|
|
|
|
if (showResume) {
|
|
return <ResumeView data={data} onClose={() => setShowResume(false)} />;
|
|
}
|
|
|
|
const tabs = [
|
|
{
|
|
key: 'profile',
|
|
label: '프로필',
|
|
content: <ProfileTab data={data} editing={editing} api={api} onRefresh={refresh} />,
|
|
},
|
|
{
|
|
key: 'projects',
|
|
label: '프로젝트',
|
|
content: <ProjectTab projects={data.projects} editing={editing} api={api} onRefresh={refresh} />,
|
|
},
|
|
{
|
|
key: 'intro',
|
|
label: '자기소개',
|
|
content: <IntroTab introductions={data.introductions || []} editing={editing} api={api} onRefresh={refresh} />,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="pf-page">
|
|
<div className="pf-toolbar">
|
|
<button className={`button ${editing ? 'primary' : 'ghost'}`} onClick={handleEditToggle}>
|
|
{editing ? '편집 완료' : '편집'}
|
|
</button>
|
|
<button className="button ghost" onClick={() => setShowResume(true)}>
|
|
PDF 내보내기
|
|
</button>
|
|
</div>
|
|
|
|
{isMobile ? (
|
|
<SwipeableView tabs={tabs} />
|
|
) : (
|
|
<SwipeableView tabs={tabs} />
|
|
)}
|
|
|
|
<PasswordModal
|
|
open={showPwModal}
|
|
onAuth={handleAuth}
|
|
onClose={() => setShowPwModal(false)}
|
|
error={api.authError}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Portfolio.css 작성**
|
|
|
|
이 파일은 전체 포트폴리오 페이지 + 모든 컴포넌트의 스타일을 포함합니다.
|
|
CSS 파일이 길지만, 프로젝트 패턴(단일 CSS per page)을 따릅니다.
|
|
실제 구현 시 기존 사이버펑크 테마 변수(`var(--surface-card)`, `var(--line)`, `var(--text-bright)` 등)를 활용하여 작성합니다.
|
|
|
|
주요 클래스 목록:
|
|
- `.pf-page` — 페이지 루트 (grid, gap)
|
|
- `.pf-toolbar` — 편집/PDF 버튼 바
|
|
- `.pf-modal-backdrop`, `.pf-modal` — 비밀번호 모달
|
|
- `.pf-profile-card` — 프로필 카드
|
|
- `.pf-section` — 경력/기술 섹션 래퍼
|
|
- `.pf-career-group`, `.pf-career-item` — 경력 타임라인
|
|
- `.pf-skill-group`, `.pf-skill-tag` — 기술 태그
|
|
- `.pf-edit-form` — 인라인 편집 폼 (공통)
|
|
- `.pf-filter-bar`, `.pf-filter-btn` — 프로젝트 필터
|
|
- `.pf-project-grid`, `.pf-project-card` — 프로젝트 카드
|
|
- `.pf-tech-input`, `.pf-tech-tag` — 기술 태그 입력
|
|
- `.pf-intro-tab`, `.pf-intro-card` — 자기소개 카드
|
|
- `.pf-resume-overlay`, `.pf-resume` — PDF 이력서 뷰
|
|
- `@media print` — 인쇄 전용 스타일
|
|
- `@media (max-width: 768px)` — 모바일 반응형
|
|
|
|
(CSS 전체 코드는 구현 subagent가 위 클래스 구조에 맞춰 사이버펑크 테마로 작성)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/Portfolio.jsx src/pages/portfolio/Portfolio.css
|
|
git commit -m "feat(portfolio): 메인 페이지 + 3탭 구조 + CSS"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Frontend — IntroTab에 introductions 데이터 전달 수정
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/pages/portfolio/Portfolio.jsx`
|
|
|
|
- [ ] **Step 1: public API에서 introductions도 가져오도록 수정**
|
|
|
|
현재 `get_public_data()`가 `main_introduction`만 반환하므로, IntroTab에서 전체 목록이 필요합니다. 두 가지 선택:
|
|
|
|
편집 모드 진입 시 `fetchIntros()`를 별도 호출하여 전체 목록을 로드합니다.
|
|
|
|
Portfolio.jsx의 state에 `intros` 추가:
|
|
|
|
```jsx
|
|
const [intros, setIntros] = useState([]);
|
|
```
|
|
|
|
`handleAuth` 성공 후 intros 로드:
|
|
```jsx
|
|
const handleAuth = async (pw) => {
|
|
const ok = await api.login(pw);
|
|
if (ok) {
|
|
setShowPwModal(false);
|
|
setEditing(true);
|
|
try {
|
|
const list = await api.fetchIntros();
|
|
setIntros(list);
|
|
} catch { /* 무시 */ }
|
|
}
|
|
};
|
|
```
|
|
|
|
refresh에서도:
|
|
```jsx
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const d = await api.fetchPublic();
|
|
setData(d);
|
|
if (api.token) {
|
|
const list = await api.fetchIntros();
|
|
setIntros(list);
|
|
}
|
|
} catch { /* 무시 */ }
|
|
}, [api.token]);
|
|
```
|
|
|
|
IntroTab에 전달:
|
|
```jsx
|
|
content: <IntroTab introductions={editing ? intros : (data.main_introduction ? [data.main_introduction] : [])} editing={editing} api={api} onRefresh={refresh} />,
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/portfolio/Portfolio.jsx
|
|
git commit -m "fix(portfolio): IntroTab에 편집 모드 시 전체 자기소개 목록 로드"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Frontend — 홈 페이지 Profile 섹션 연동
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/pages/home/Home.jsx`
|
|
- Modify: `web-ui/src/pages/home/Home.css`
|
|
|
|
- [ ] **Step 1: Home.jsx Profile 섹션을 API 연동 요약 카드로 교체**
|
|
|
|
기존 하드코딩 Profile 섹션(line 215~271)을 교체합니다.
|
|
|
|
Home 컴포넌트 상단에 state 추가:
|
|
```jsx
|
|
const [portfolio, setPortfolio] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/profile/public')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.catch(() => null)
|
|
.then(d => setPortfolio(d));
|
|
}, []);
|
|
```
|
|
|
|
기존 Profile 섹션을 교체:
|
|
```jsx
|
|
<section className="home-section">
|
|
<div className="home-section__header">
|
|
<h2>Profile</h2>
|
|
<p>페이지 주인 소개 영역입니다.</p>
|
|
</div>
|
|
<div className="home-profile">
|
|
<div className="home-profile__card">
|
|
<div className="home-profile__identity">
|
|
<img
|
|
className="home-profile__avatar"
|
|
src={portfolio?.profile?.photo_url || myPhoto}
|
|
alt="Profile"
|
|
/>
|
|
<div>
|
|
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
|
|
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
|
|
</div>
|
|
</div>
|
|
<p className="home-profile__bio">
|
|
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
|
|
</p>
|
|
<div className="home-profile__tags">
|
|
{(portfolio?.skills || []).slice(0, 8).map((s) => (
|
|
<span key={s.id || s.name}>{s.name}</span>
|
|
))}
|
|
{!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
|
<span key={tag}>{tag}</span>
|
|
))}
|
|
</div>
|
|
<div className="home-profile__actions">
|
|
<Link className="button ghost" to="/portfolio">
|
|
포트폴리오 보기
|
|
</Link>
|
|
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
|
|
연락하기
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
```
|
|
|
|
기존 연혁(timeline) 섹션과 "프로필 수정" 버튼은 제거합니다 — 포트폴리오 페이지에서 관리.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd web-ui
|
|
git add src/pages/home/Home.jsx src/pages/home/Home.css
|
|
git commit -m "refactor(home): Profile 섹션 portfolio API 연동 + 요약 카드"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: CLAUDE.md 문서 업데이트
|
|
|
|
**Files:**
|
|
- Modify: `web-backend/CLAUDE.md`
|
|
|
|
- [ ] **Step 1: portfolio 서비스 섹션 추가**
|
|
|
|
Docker 서비스 & 포트 테이블에 추가:
|
|
```
|
|
| `portfolio` | 18850 | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개 관리) |
|
|
```
|
|
|
|
Nginx 라우팅 규칙 테이블에 추가:
|
|
```
|
|
| `/api/profile/` | portfolio | 포트폴리오 API |
|
|
```
|
|
|
|
서비스별 핵심 정보에 portfolio 섹션 추가:
|
|
|
|
```markdown
|
|
### portfolio (portfolio/)
|
|
- 개인 포트폴리오 서비스 (프로필, 경력, 프로젝트, 기술스택, 자기소개 관리)
|
|
- DB: `/app/data/portfolio.db` (profile, careers, projects, skills, introductions 테이블)
|
|
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
|
|
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
|
|
|
|
**환경변수**
|
|
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
|
|
|
|
**portfolio API 목록**
|
|
|
|
| 메서드 | 경로 | 설명 |
|
|
|--------|------|------|
|
|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
|
|
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
|
|
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
|
|
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
|
|
| GET | `/api/profile/careers` | 경력 목록 (인증) |
|
|
| POST | `/api/profile/careers` | 경력 추가 (인증) |
|
|
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
|
|
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
|
|
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
|
|
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
|
|
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
|
|
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
|
|
| GET | `/api/profile/skills` | 기술 목록 (인증) |
|
|
| POST | `/api/profile/skills` | 기술 추가 (인증) |
|
|
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
|
|
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
|
|
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
|
|
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
|
|
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
|
|
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
|
|
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add CLAUDE.md
|
|
git commit -m "docs: CLAUDE.md에 portfolio 서비스 추가"
|
|
```
|