diff --git a/docs/superpowers/plans/2026-04-27-portfolio.md b/docs/superpowers/plans/2026-04-27-portfolio.md new file mode 100644 index 0000000..effd678 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-portfolio.md @@ -0,0 +1,2129 @@ +# 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 = () => + + + + + + ; +``` + +- [ ] **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: , + accent: '#06b6d4', + }, +``` + +appRoutes 배열에서 `agent-office` 항목 앞에 추가: +```jsx + { + path: 'portfolio', + element: , + }, +``` + +- [ ] **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 ( +
+
e.stopPropagation()}> +

편집 모드

+

편집하려면 비밀번호를 입력하세요.

+
+ setPw(e.target.value)} + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+
+
+ ); +} +``` + +- [ ] **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 ( +
+ {/* ── 프로필 카드 ── */} +
+ {editingProfile ? ( +
+ + + + + + + + + +