백엔드 DB/API + 프론트 3탭 + 인프라 + 홈 연동 전체 구현 계획. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
78 KiB
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: 빈 패키지 파일 생성
# portfolio/app/__init__.py
# (빈 파일)
- Step 2: db.py 작성 — 연결 헬퍼 + 5개 테이블 초기화
# 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 끝에 추가:
# ── 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)
# ── 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)
# ── 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
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 작성
# 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 작성 — 토큰 관리
# 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
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 작성
# 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
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 작성
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
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: 블록 앞에 삽입:
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 블록 앞에 삽입:
# 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를 수정:
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:
BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend"
Line 12:
CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy portfolio lotto-frontend"
Line 14:
HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio"
Line 16:
DATA_DIRS="music stock blog realestate agent-office portfolio"
- Step 5: Commit
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 뒤에 추가:
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 섹션에 추가:
import { IconPortfolio } from './components/Icons';
lazy import 추가 (기존 lazy import 블록 끝에):
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
navLinks 배열에서 agent-office 항목 앞에 추가:
{
id: 'portfolio',
label: 'Portfolio',
path: '/portfolio',
subtitle: 'RESUME',
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
icon: <IconPortfolio />,
accent: '#06b6d4',
},
appRoutes 배열에서 agent-office 항목 앞에 추가:
{
path: 'portfolio',
element: <Portfolio />,
},
- Step 3: usePortfolioApi.js 작성
// 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
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 작성
// 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
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 작성
// 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
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 작성
// 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
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 작성
// 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
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 작성
// 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
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 작성
// 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
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 추가:
const [intros, setIntros] = useState([]);
handleAuth 성공 후 intros 로드:
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에서도:
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에 전달:
content: <IntroTab introductions={editing ? intros : (data.main_introduction ? [data.main_introduction] : [])} editing={editing} api={api} onRefresh={refresh} />,
- Step 2: Commit
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 추가:
const [portfolio, setPortfolio] = useState(null);
useEffect(() => {
fetch('/api/profile/public')
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => setPortfolio(d));
}, []);
기존 Profile 섹션을 교체:
<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
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 섹션 추가:
### 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
git add CLAUDE.md
git commit -m "docs: CLAUDE.md에 portfolio 서비스 추가"