Compare commits
16 Commits
ae4f0d4270
...
cb6e2d992a
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6e2d992a | |||
| 7011d3ef3a | |||
| eb322b7450 | |||
| 4fde9e6f58 | |||
| 7d78fae77f | |||
| e82ff83a5f | |||
| fac2e65ed8 | |||
| 42242f86eb | |||
| c5682e07a7 | |||
| 8f0b1fbbfa | |||
| e88989d3c1 | |||
| f38631cdae | |||
| b2accba65a | |||
| 8d92e50009 | |||
| bd7875b36a | |||
| 5ac5cce0fe |
17
CLAUDE.md
17
CLAUDE.md
@@ -349,9 +349,18 @@ docker compose up -d
|
||||
### travel-proxy (travel-proxy/)
|
||||
- 원본 사진: `/data/travel/` (RO)
|
||||
- 썸네일 캐시: `/data/thumbs/` (RW)
|
||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
||||
- 썸네일: 480×480 리사이징 (Pillow), 온디맨드 생성 후 영구 캐시
|
||||
- 메모리 캐시: TTL 300초 (앨범 스캔 결과)
|
||||
- 파일 구조: `main.py`, `db.py`, `indexer.py`
|
||||
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
|
||||
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
||||
|
||||
**travel.db 테이블**
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
|
||||
| `album_covers` | 앨범별 커버 사진 지정 |
|
||||
|
||||
**travel-proxy API 목록**
|
||||
|
||||
@@ -359,7 +368,9 @@ docker compose up -d
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||
| POST | `/api/travel/reload` | 메모리 캐시 초기화 |
|
||||
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
|
||||
### blog-lab (blog-lab/)
|
||||
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
|
||||
|
||||
@@ -161,7 +161,7 @@ services:
|
||||
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
|
||||
2392
docs/superpowers/plans/2026-04-23-responsive-web-design.md
Normal file
2392
docs/superpowers/plans/2026-04-23-responsive-web-design.md
Normal file
File diff suppressed because it is too large
Load Diff
2665
docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
Normal file
2665
docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
681
docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
Normal file
681
docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
Normal file
@@ -0,0 +1,681 @@
|
||||
# Travel-Proxy 성능 개선 구현 계획
|
||||
|
||||
> **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:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||
|
||||
**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
|
||||
|
||||
**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
|
||||
|
||||
---
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
|
||||
| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
|
||||
| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Create: `travel-proxy/app/db.py`
|
||||
|
||||
- [ ] **Step 1: db.py 파일 생성**
|
||||
|
||||
```python
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
album TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
has_thumb INTEGER DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(album, filename)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS album_covers (
|
||||
album TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
|
||||
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
|
||||
if not albums:
|
||||
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
|
||||
|
||||
placeholders = ",".join("?" for _ in albums)
|
||||
|
||||
with _conn() as conn:
|
||||
# 앨범별 사진 수
|
||||
rows = conn.execute(
|
||||
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
|
||||
albums,
|
||||
).fetchall()
|
||||
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
|
||||
|
||||
# 전체 수
|
||||
total_row = conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
|
||||
albums,
|
||||
).fetchone()
|
||||
total = total_row["cnt"]
|
||||
|
||||
# 페이지네이션
|
||||
offset = (page - 1) * size
|
||||
items = conn.execute(
|
||||
f"""SELECT album, filename, mtime FROM photos
|
||||
WHERE album IN ({placeholders})
|
||||
ORDER BY album, filename
|
||||
LIMIT ? OFFSET ?""",
|
||||
[*albums, size, offset],
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(r) for r in items],
|
||||
"total": total,
|
||||
"has_next": (offset + size) < total,
|
||||
"matched_albums": matched_albums,
|
||||
}
|
||||
|
||||
|
||||
def get_all_albums() -> List[Dict[str, Any]]:
|
||||
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT p.album, COUNT(*) as count,
|
||||
COALESCE(c.filename, MIN(p.filename)) as cover_filename
|
||||
FROM photos p
|
||||
LEFT JOIN album_covers c ON p.album = c.album
|
||||
GROUP BY p.album
|
||||
ORDER BY p.album
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_album_cover(album: str, filename: str) -> bool:
|
||||
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
|
||||
with _conn() as conn:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return False
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO album_covers (album, filename, updated_at)
|
||||
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(album) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
updated_at = excluded.updated_at""",
|
||||
(album, filename),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_album_cover(album: str) -> Optional[str]:
|
||||
"""앨범 커버 파일명 반환. 미지정 시 None."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM album_covers WHERE album = ?",
|
||||
(album,),
|
||||
).fetchone()
|
||||
return row["filename"] if row else None
|
||||
|
||||
|
||||
def upsert_photo(album: str, filename: str, mtime: float) -> str:
|
||||
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
|
||||
with _conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||
(album, filename, mtime),
|
||||
)
|
||||
return "added"
|
||||
elif existing["mtime"] != mtime:
|
||||
conn.execute(
|
||||
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||
(mtime, album, filename),
|
||||
)
|
||||
return "updated"
|
||||
return "unchanged"
|
||||
|
||||
|
||||
def remove_missing_photos(album: str, existing_filenames: set) -> int:
|
||||
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
|
||||
with _conn() as conn:
|
||||
db_rows = conn.execute(
|
||||
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||
).fetchall()
|
||||
db_filenames = {r["filename"] for r in db_rows}
|
||||
to_remove = db_filenames - existing_filenames
|
||||
|
||||
if to_remove:
|
||||
placeholders = ",".join("?" for _ in to_remove)
|
||||
conn.execute(
|
||||
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
# 삭제된 파일이 커버였으면 커버도 제거
|
||||
conn.execute(
|
||||
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
return len(to_remove)
|
||||
|
||||
|
||||
def get_photos_without_thumb() -> List[Dict[str, str]]:
|
||||
"""썸네일 미생성 사진 목록."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT album, filename FROM photos WHERE has_thumb = 0"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_thumb_done(album: str, filename: str) -> None:
|
||||
"""썸네일 생성 완료 표시."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/db.py
|
||||
git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
|
||||
|
||||
**Files:**
|
||||
- Create: `travel-proxy/app/indexer.py`
|
||||
|
||||
- [ ] **Step 1: indexer.py 파일 생성**
|
||||
|
||||
기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
|
||||
|
||||
```python
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
|
||||
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
|
||||
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
|
||||
if not folder.exists():
|
||||
return []
|
||||
items = []
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||
items.append({
|
||||
"filename": entry.name,
|
||||
"mtime": entry.stat().st_mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _generate_thumb(src: Path, dest: Path) -> bool:
|
||||
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = dest.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(dest)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Thumb generation failed: %s → %s", src, e)
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def sync(
|
||||
travel_root: Path,
|
||||
thumb_root: Path,
|
||||
region_map_path: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||
|
||||
Returns:
|
||||
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# 1. region_map.json에서 전체 앨범 폴더 수집
|
||||
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||
region_map = json.load(f)
|
||||
|
||||
all_albums: Set[str] = set()
|
||||
for v in region_map.values():
|
||||
if isinstance(v, list):
|
||||
all_albums.update(v)
|
||||
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
all_albums.update(v["albums"])
|
||||
|
||||
# 2. 각 앨범 폴더 스캔 → DB 동기화
|
||||
added = 0
|
||||
removed = 0
|
||||
|
||||
for album in sorted(all_albums):
|
||||
folder = travel_root / album
|
||||
items = _scan_folder(folder)
|
||||
existing_filenames = set()
|
||||
|
||||
for item in items:
|
||||
existing_filenames.add(item["filename"])
|
||||
result = db.upsert_photo(album, item["filename"], item["mtime"])
|
||||
if result == "added":
|
||||
added += 1
|
||||
|
||||
removed += db.remove_missing_photos(album, existing_filenames)
|
||||
|
||||
# 3. 썸네일 미생성 분 일괄 생성
|
||||
no_thumb = db.get_photos_without_thumb()
|
||||
thumbs_generated = 0
|
||||
|
||||
for photo in no_thumb:
|
||||
src = travel_root / photo["album"] / photo["filename"]
|
||||
dest = thumb_root / photo["album"] / photo["filename"]
|
||||
if _generate_thumb(src, dest):
|
||||
db.mark_thumb_done(photo["album"], photo["filename"])
|
||||
thumbs_generated += 1
|
||||
|
||||
duration = round(time.time() - start, 2)
|
||||
logger.info(
|
||||
"Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
|
||||
added, removed, thumbs_generated, duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"thumbs_generated": thumbs_generated,
|
||||
"duration_sec": duration,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/indexer.py
|
||||
git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
|
||||
|
||||
**Files:**
|
||||
- Modify: `travel-proxy/app/main.py`
|
||||
|
||||
이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
|
||||
|
||||
- [ ] **Step 1: main.py를 DB 기반으로 재작성**
|
||||
|
||||
main.py 전체를 아래로 교체:
|
||||
|
||||
```python
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from PIL import Image
|
||||
|
||||
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
|
||||
from .indexer import sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# -----------------------------
|
||||
# Env / Paths
|
||||
# -----------------------------
|
||||
ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve()
|
||||
MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel")
|
||||
|
||||
META_DIR = ROOT / "_meta"
|
||||
REGION_MAP_PATH = META_DIR / "region_map.json"
|
||||
REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson"
|
||||
|
||||
THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve()
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
# -----------------------------
|
||||
# DB init
|
||||
# -----------------------------
|
||||
init_db()
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
def _read_json(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
raise HTTPException(500, f"Missing required file: {path}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_region_map() -> dict:
|
||||
return _read_json(REGION_MAP_PATH)
|
||||
|
||||
|
||||
def load_regions_geojson() -> dict:
|
||||
return _read_json(REGIONS_GEOJSON_PATH)
|
||||
|
||||
|
||||
def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
|
||||
if region not in region_map:
|
||||
raise HTTPException(400, "Unknown region")
|
||||
v = region_map[region]
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
return v["albums"]
|
||||
raise HTTPException(500, "Invalid region_map format")
|
||||
|
||||
|
||||
def _ensure_thumb_fallback(src: Path, album: str) -> Path:
|
||||
"""온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
|
||||
out = THUMB_ROOT / album / src.name
|
||||
if out.exists():
|
||||
return out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = out.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(out)
|
||||
return out
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Models
|
||||
# -----------------------------
|
||||
class CoverRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Routes
|
||||
# -----------------------------
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy", "service": "travel-proxy"}
|
||||
|
||||
|
||||
@app.get("/api/travel/regions")
|
||||
def regions():
|
||||
return load_regions_geojson()
|
||||
|
||||
|
||||
@app.get("/api/travel/photos")
|
||||
def photos(
|
||||
region: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
region_map = load_region_map()
|
||||
albums = _get_albums_for_region(region, region_map)
|
||||
result = get_photos_by_region(albums, page, size)
|
||||
|
||||
# URL 조합 (DB에는 경로를 저장하지 않음)
|
||||
items = []
|
||||
for row in result["items"]:
|
||||
items.append({
|
||||
"album": row["album"],
|
||||
"file": row["filename"],
|
||||
"url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
|
||||
"mtime": row["mtime"],
|
||||
})
|
||||
|
||||
return {
|
||||
"region": region,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total": result["total"],
|
||||
"has_next": result["has_next"],
|
||||
"items": items,
|
||||
"matched_albums": result["matched_albums"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/travel/sync")
|
||||
def sync_endpoint():
|
||||
result = sync(
|
||||
travel_root=ROOT,
|
||||
thumb_root=THUMB_ROOT,
|
||||
region_map_path=REGION_MAP_PATH,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/travel/albums")
|
||||
def albums_list():
|
||||
rows = get_all_albums()
|
||||
result = []
|
||||
for r in rows:
|
||||
cover = r["cover_filename"]
|
||||
result.append({
|
||||
"album": r["album"],
|
||||
"count": r["count"],
|
||||
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/travel/albums/{album}/cover")
|
||||
def set_cover(album: str, body: CoverRequest):
|
||||
ok = set_album_cover(album, body.filename)
|
||||
if not ok:
|
||||
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
|
||||
return {
|
||||
"album": album,
|
||||
"filename": body.filename,
|
||||
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||
def get_thumb(album: str, filename: str):
|
||||
if ".." in album or ".." in filename:
|
||||
raise HTTPException(400, "Invalid path")
|
||||
src = (ROOT / album / filename).resolve()
|
||||
if not str(src).startswith(str(ROOT)):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if not src.exists() or not src.is_file():
|
||||
raise HTTPException(404, "Source not found")
|
||||
p = _ensure_thumb_fallback(src, album)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
return FileResponse(str(p))
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/main.py
|
||||
git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 통합 검증
|
||||
|
||||
**Files:**
|
||||
- 없음 (기존 파일 검증만)
|
||||
|
||||
- [ ] **Step 1: import 구조 확인**
|
||||
|
||||
travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
|
||||
|
||||
```bash
|
||||
ls travel-proxy/app/
|
||||
```
|
||||
|
||||
`__init__.py`가 없으면 생성:
|
||||
|
||||
```python
|
||||
# travel-proxy/app/__init__.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Dockerfile 확인**
|
||||
|
||||
현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
|
||||
|
||||
- [ ] **Step 3: docker-compose.yml 환경변수 확인**
|
||||
|
||||
`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml의 travel-proxy 서비스 environment에 추가
|
||||
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: photos 응답 호환성 검증**
|
||||
|
||||
기존 응답 필드와 비교:
|
||||
- `region` ✓
|
||||
- `page`, `size` ✓
|
||||
- `total`, `has_next` ✓
|
||||
- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓
|
||||
- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
|
||||
|
||||
- [ ] **Step 5: 커밋 (변경 있을 시)**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/__init__.py docker-compose.yml
|
||||
git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CLAUDE.md 업데이트
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
|
||||
|
||||
CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
|
||||
|
||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||
- 파일 구조에 `db.py`, `indexer.py` 추가
|
||||
|
||||
API 목록 테이블에 신규 API 3개 추가:
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
|
||||
`POST /api/travel/reload` 제거 표기.
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
|
||||
```
|
||||
360
docs/superpowers/specs/2026-04-23-responsive-web-design.md
Normal file
360
docs/superpowers/specs/2026-04-23-responsive-web-design.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# 반응형 웹 UI/UX 전면 개선 설계
|
||||
|
||||
> 모바일에서 UI 짤림 현상 해결 + 풀 모바일 경험 적용
|
||||
> 작성일: 2026-04-23
|
||||
> 리뷰 반영: 2026-04-23 (라우트 경로 수정, breakpoint 예외 명시, 구현 복잡도 보완)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
- 전체 15개 뷰(12개 라우트 + 3개 서브라우트)에서 모바일 UI 짤림 현상 해결
|
||||
- 현재 다크 네온 사이버펑크 디자인 톤 유지
|
||||
- 모바일 전용 UX 패턴 추가 (바텀 네비게이션, 스와이프, 풀다운 리프레시, FAB, 바텀시트)
|
||||
- 기능적 손실 없이 반응형 적용
|
||||
|
||||
**대상 뷰 목록 (routes.jsx 기준):**
|
||||
|
||||
| # | 라우트 | 컴포넌트 | 비고 |
|
||||
|---|--------|---------|------|
|
||||
| 1 | `/` | Home | |
|
||||
| 2 | `/lotto` | Lotto | 3탭 (Briefing/Analysis/Purchase) |
|
||||
| 3 | `/stock` | Stock | |
|
||||
| 4 | `/stock/trade` | StockTrade | 서브라우트 |
|
||||
| 5 | `/travel` | Travel | |
|
||||
| 6 | `/blog` | Blog | |
|
||||
| 7 | `/blog-lab` | BlogMarketing | |
|
||||
| 8 | `/realestate` | Subscription | |
|
||||
| 9 | `/music` | MusicStudio | |
|
||||
| 10 | `/todo` | Todo | |
|
||||
| 11 | `/agent-office` | AgentOffice | |
|
||||
| 12 | `/lab` | EffectLab | |
|
||||
| 13 | `/lab/sword-stream` | SwordStream | 서브라우트 |
|
||||
| 14 | `/lab/day-calc` | DayCalc | 서브라우트 |
|
||||
|
||||
> Note: `RealEstate.jsx` (`/realestate/property`)는 routes.jsx에 미등록 상태. 반응형 스코프에서 제외.
|
||||
|
||||
---
|
||||
|
||||
## 2. 접근 방식
|
||||
|
||||
**글로벌 모바일 시스템 구축 → 주요 페이지 적용 → 전체 페이지 확장**
|
||||
|
||||
1. 공통 모바일 인프라(컴포넌트, breakpoint, 앱 셸) 구축
|
||||
2. 주요 4개 페이지 (홈, 로또, 주식, 여행) 우선 적용
|
||||
3. 나머지 페이지 확장 적용
|
||||
|
||||
---
|
||||
|
||||
## 3. 글로벌 모바일 인프라
|
||||
|
||||
### 3-1. Breakpoint 시스템 통일
|
||||
|
||||
현재 53개 미디어 쿼리에서 다양한 값이 혼재. 4단계로 통일:
|
||||
|
||||
| 이름 | 값 | 용도 |
|
||||
|------|-----|------|
|
||||
| sm | 480px | 소형 폰 |
|
||||
| md | 768px | 태블릿/대형 폰 (주요 분기점) |
|
||||
| lg | 1024px | 소형 데스크톱 |
|
||||
| xl | 1280px | 대형 데스크톱 |
|
||||
|
||||
기존 미디어 쿼리의 비표준 값(640px, 900px, 960px, 1100px 등)은 기능 손실 없이 가장 가까운 표준 breakpoint로 정리한다.
|
||||
|
||||
**허용 예외 (이동 시 시각적 회귀 발생):**
|
||||
|
||||
| 기존 값 | 파일 | 사유 |
|
||||
|---------|------|------|
|
||||
| 420px | Stock.css (4곳) | 소형 폰 전용 패딩/라벨 축소, 480px로 이동 시 중간 기기에서 불필요한 축소 |
|
||||
| 520px | Stock.css (1곳) | 지표 카드 특수 레이아웃 |
|
||||
| 700px | Stock.css (1곳) | AI 코치 설정 그리드, 768px로 이동 시 태블릿에서 조기 축소 |
|
||||
|
||||
위 값들은 해당 페이지 CSS에서 기존 값을 유지한다.
|
||||
|
||||
### 3-2. 바텀 네비게이션 바 (`BottomNav`)
|
||||
|
||||
- 768px 이하에서 사이드바 대신 표시
|
||||
- 주요 5개 메뉴 아이콘 + "더보기" 메뉴 (나머지 페이지)
|
||||
- 현재 페이지 활성 표시 — 네온 시안 글로우 유지
|
||||
- 사이드바는 모바일에서 완전히 숨김 (기존 햄버거→슬라이드 방식 제거)
|
||||
- 높이: 56~64px
|
||||
- `env(safe-area-inset-bottom)` 대응 (노치/홈 인디케이터 기기)
|
||||
- `index.html`에 `viewport-fit=cover` 추가 필요: `<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">`
|
||||
- 더보기 메뉴: 탭 시 위로 펼쳐지는 오버레이 패널
|
||||
|
||||
**사이드바→바텀네비 마이그레이션 상세:**
|
||||
- `Navbar.jsx`: 768px 이하에서 사이드바 렌더링 제거, `sidebar-toggle` 버튼 제거
|
||||
- `Navbar.css`: `.sidebar` transform/transition 미디어 쿼리 제거, `.sidebar__overlay` 제거
|
||||
- `Navbar.jsx` useEffect: `body.overflow = 'hidden'` 토글 로직 정리
|
||||
- `App.jsx`에서 `BottomNav` 컴포넌트 조건부 렌더링 (`useIsMobile()` 기반)
|
||||
|
||||
**더보기 메뉴 내용 (나머지 네비게이션 항목):**
|
||||
|
||||
| 순서 | 아이콘 | 라벨 | 경로 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 음악 | 뮤직 | `/music` |
|
||||
| 2 | 로봇 | 에이전트 | `/agent-office` |
|
||||
| 3 | 블로그 | 블로그 | `/blog` |
|
||||
| 4 | 마케팅 | 블로그랩 | `/blog-lab` |
|
||||
| 5 | 건물 | 청약 | `/realestate` |
|
||||
| 6 | 체크 | TODO | `/todo` |
|
||||
| 7 | 실험 | 이펙트랩 | `/lab` |
|
||||
|
||||
**기본 5개 메뉴 구성:**
|
||||
|
||||
| 순서 | 아이콘 | 라벨 | 경로 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 홈 | 홈 | `/` |
|
||||
| 2 | 클로버 | 로또 | `/lotto` |
|
||||
| 3 | 차트 | 주식 | `/stock` |
|
||||
| 4 | 카메라 | 여행 | `/travel` |
|
||||
| 5 | 더보기 | 메뉴 | 오버레이 |
|
||||
|
||||
### 3-3. 공통 모바일 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 | 역할 |
|
||||
|---------|------|------|
|
||||
| `BottomNav` | `src/components/BottomNav.jsx` | 하단 고정 네비게이션 |
|
||||
| `PullToRefresh` | `src/components/PullToRefresh.jsx` | 터치 풀다운 새로고침 래퍼 |
|
||||
| `SwipeableView` | `src/components/SwipeableView.jsx` | 좌우 스와이프 탭/뷰 전환 |
|
||||
| `FAB` | `src/components/FAB.jsx` | 플로팅 액션 버튼 (바텀 네비 위 배치) |
|
||||
| `MobileSheet` | `src/components/MobileSheet.jsx` | 바텀시트 모달 (드래그 핸들, 스냅 포인트) |
|
||||
|
||||
**공통 훅 (신규 `src/hooks/` 디렉토리 생성):**
|
||||
|
||||
> 기존 훅은 페이지별 디렉토리에 colocate (`src/pages/lotto/hooks/` 등).
|
||||
> 모바일 인프라 훅은 여러 페이지에서 공유하므로 `src/hooks/`에 배치한다.
|
||||
|
||||
| 훅 | 파일 | 역할 |
|
||||
|----|------|------|
|
||||
| `useIsMobile` | `src/hooks/useIsMobile.js` | 768px 이하 감지 (matchMedia) |
|
||||
| `useSwipe` | `src/hooks/useSwipe.js` | 터치 스와이프 방향·거리 감지 |
|
||||
|
||||
**경량 라이브러리 활용:**
|
||||
- `react-swipeable` (~3KB gzipped): SwipeableView/useSwipe 기반으로 활용 — 터치 velocity, threshold snap, 방향 판별을 직접 구현하지 않음
|
||||
- PullToRefresh: 터치 이벤트 직접 구현하되, iOS Safari rubber-banding 및 `overscroll-behavior: contain` 대응 필수
|
||||
- MobileSheet: CSS `transform` + `touch-action: none`으로 구현, 스냅 포인트 2단계 (50%, 90%)
|
||||
|
||||
### 3-4. 앱 셸 레이아웃 변경
|
||||
|
||||
```
|
||||
데스크톱: [사이드바 240px] [콘텐츠]
|
||||
모바일: [탑바 56px]
|
||||
[콘텐츠 (padding-bottom: 바텀네비 높이)]
|
||||
[바텀 네비 56-64px]
|
||||
```
|
||||
|
||||
- 콘텐츠 영역에 `padding-bottom` 추가 (바텀 네비 겹침 방지)
|
||||
- 탑바: 현재 구조 유지, 페이지 타이틀 + 액션 버튼 영역
|
||||
- `body` overflow: 모바일에서 auto (현재와 동일)
|
||||
|
||||
---
|
||||
|
||||
## 4. 주요 페이지별 모바일 설계
|
||||
|
||||
### 4-1. 홈 (Home) — `/`
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 (≤768px) |
|
||||
|------|---------|-----------------|
|
||||
| 히어로 | 2컬럼 그리드 | 1컬럼 스택, 타이틀 축소 |
|
||||
| 네비 카드 그리드 | auto-fill minmax(180px) | 2컬럼 고정, 카드 높이 축소 |
|
||||
| TODO 보드 | 3컬럼 칸반 | 스와이프 탭 (Todo/진행중/완료) |
|
||||
| 블로그 포스트 | 카드 그리드 | 1컬럼 리스트 |
|
||||
| 프로필 섹션 | 사이드 카드 | 하단 접이식 패널 |
|
||||
|
||||
- 풀다운 리프레시: 블로그 포스트 갱신
|
||||
- FAB: 없음 (네비게이션 허브)
|
||||
|
||||
### 4-2. 로또 (Lotto) — `/lotto`
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 3탭 구조 | 상단 탭바 | 스와이프 탭 전환 |
|
||||
| 브리핑 탭 | 카드 레이아웃 | 1컬럼, 볼 크기 36→32px |
|
||||
| 분석 탭 | 그리드 카드 | 1컬럼 스택 |
|
||||
| 구매 이력 테이블 | 6컬럼 그리드 | 가로 스크롤 테이블 + 행 터치 바텀시트 |
|
||||
| 번호 추천 카드 | 다중 그리드 | 1컬럼, 볼 간격 조정 |
|
||||
| 전략 차트 | 넓은 차트 | 가로스크롤 또는 축소 |
|
||||
|
||||
- FAB: "추천받기" (빠른 번호 추천)
|
||||
- 풀다운 리프레시: 브리핑/분석 데이터 갱신
|
||||
|
||||
### 4-3. 주식 (Stock / StockTrade) — `/stock`, `/stock/trade`
|
||||
|
||||
**Stock (뉴스/지표)**
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 헤더 | 2컬럼 | 1컬럼 스택 |
|
||||
| 뉴스 그리드 | auto-fit minmax(260px) | 1컬럼 카드 리스트 |
|
||||
| 필터 | 가로 나열 | 가로 스크롤 칩 바 |
|
||||
| 지표 카드 | 그리드 | 가로 스크롤 카드 캐러셀 |
|
||||
|
||||
**StockTrade (매매)**
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 포트폴리오 테이블 | 넓은 테이블 | 카드형 리스트 (종목별 카드) |
|
||||
| 매도 이력 | 테이블 | 가로 스크롤 + 행 터치 바텀시트 |
|
||||
| 자산 차트 | 넓은 recharts | 풀 너비, 축 라벨 축소 |
|
||||
| 예수금 섹션 | 인라인 | 접이식 카드 |
|
||||
|
||||
- FAB: "종목 추가" (Stock), "매도 기록" (StockTrade)
|
||||
- 풀다운 리프레시: 뉴스/포트폴리오 갱신
|
||||
|
||||
### 4-4. 여행 (Travel) — `/travel`
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 지역 선택 | Leaflet 지도 | 높이 50vh→35vh, 핀치 줌 |
|
||||
| 사진 그리드 | 다중 컬럼 | 2컬럼 → 1컬럼 (≤480px) |
|
||||
| 사진 상세 | 모달 | 풀스크린 뷰어 + 스와이프 넘기기 |
|
||||
| 지역 필터 | 드롭다운 | 바텀시트 지역 선택 |
|
||||
|
||||
- 풀다운 리프레시: 사진 목록 갱신
|
||||
- FAB: 없음
|
||||
|
||||
---
|
||||
|
||||
## 5. 나머지 페이지 모바일 설계
|
||||
|
||||
### 5-1. 블로그 (Blog) — `/blog`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 글 목록 | 1컬럼 리스트형 |
|
||||
| 글 상세 | 풀 너비, 폰트 크기 조정 |
|
||||
| 태그 필터 | 가로 스크롤 칩 바 |
|
||||
| 작성/수정 폼 | 풀 너비, 툴바 축소 |
|
||||
|
||||
- FAB: "글 쓰기"
|
||||
- 풀다운 리프레시: 글 목록 갱신
|
||||
|
||||
### 5-2. 블로그 마케팅 (BlogMarketing) — `/blog-lab`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 대시보드 지표 | 2컬럼 → 1컬럼 (≤480px) |
|
||||
| 파이프라인 테이블 | 카드형 리스트 (상태 배지) |
|
||||
| 키워드 분석 | 접이식 아코디언 |
|
||||
| 수익 내역 | 가로 스크롤 테이블 |
|
||||
|
||||
- FAB: "키워드 분석"
|
||||
- 풀다운 리프레시: 대시보드 갱신
|
||||
|
||||
### 5-3. 부동산 청약 (Subscription) — `/realestate`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 공고 목록 | 1컬럼 카드 리스트 |
|
||||
| 필터 | 바텀시트 필터 패널 |
|
||||
| 공고 상세 | 바텀시트 상세보기 |
|
||||
| 매칭 결과 | 1컬럼, 점수 강조 |
|
||||
| 대시보드 | 2컬럼 그리드 |
|
||||
|
||||
- FAB: "공고 등록"
|
||||
- 풀다운 리프레시: 공고/매칭 갱신
|
||||
|
||||
### 5-4. 뮤직 스튜디오 (MusicStudio) — `/music`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 헤더 | 1컬럼, 타이틀 클램프 축소 |
|
||||
| 생성 폼 | 풀 너비 스택 |
|
||||
| 라이브러리 | 1컬럼 리스트 (앨범아트 + 제목) |
|
||||
| 플레이어 | 미니 플레이어 바텀 고정 (높이 56px, 바텀 네비 위 = bottom: 64px) |
|
||||
| 가사 에디터 | 풀 너비 |
|
||||
| 레이더 위젯 | 중앙 정렬 |
|
||||
|
||||
- FAB: "음악 생성"
|
||||
- 풀다운 리프레시: 라이브러리 갱신
|
||||
- 미니 플레이어 표시 시 콘텐츠 padding-bottom: 바텀네비(64px) + 미니플레이어(56px) = 120px
|
||||
|
||||
### 5-5. TODO — `/todo`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 칸반 보드 | 스와이프 탭 (Todo/진행중/완료) |
|
||||
| 할일 카드 | 스와이프로 상태 변경 |
|
||||
| 입력 폼 | FAB → 바텀시트 입력 폼 |
|
||||
|
||||
- FAB: "할일 추가"
|
||||
|
||||
### 5-6. 에이전트 오피스 (AgentOffice) — `/agent-office`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 캔버스 오피스 | 풀스크린 캔버스, 핀치 줌/패닝 |
|
||||
| 에이전트 패널 | 바텀시트 에이전트 상세 |
|
||||
| 작업 로그 | 바텀시트 로그 뷰 |
|
||||
| 명령 입력 | 하단 입력 바 (채팅 UX) |
|
||||
| WebSocket 상태 | 탑바에 연결 상태 아이콘 |
|
||||
|
||||
### 5-7. 이펙트 랩 — `/lab`, `/lab/day-calc`, `/lab/sword-stream`
|
||||
|
||||
| 페이지 | 모바일 변경 |
|
||||
|--------|------------|
|
||||
| EffectLab 허브 | 카드 그리드 → 1컬럼 리스트 |
|
||||
| DayCalc | 풀 너비 스택, 네이티브 날짜 피커 |
|
||||
| SwordStream | 풀스크린 캔버스, 터치 인터랙션 유지, 오버레이 축소 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 터치 타겟 가이드라인
|
||||
|
||||
- 모든 터치 타겟: 최소 44×44px (Apple HIG 기준)
|
||||
- 버튼 간 간격: 최소 8px
|
||||
- FAB 크기: 56×56px
|
||||
- 바텀 네비 아이템: 최소 48×48px 터치 영역
|
||||
|
||||
---
|
||||
|
||||
## 7. 성능 고려사항
|
||||
|
||||
- 모바일에서 글로우/그라디언트 효과: box-shadow 개수 줄이기 (3중→1중)
|
||||
- `background-attachment: fixed` → 모바일에서 `scroll` (현재 적용됨, 유지)
|
||||
- 이미지: `loading="lazy"` 속성 확인
|
||||
- 스와이프/터치 이벤트: passive listener 사용
|
||||
- 바텀시트 애니메이션: `transform` + `will-change` 사용 (layout thrashing 방지)
|
||||
- 신규 애니메이션(스와이프, 바텀시트, 풀다운)은 `prefers-reduced-motion: reduce` 쿼리 존중 — Travel.css, MusicStudio.css 기존 패턴과 통일
|
||||
|
||||
### 주의: Stock.css / StockTrade.jsx 커플링
|
||||
|
||||
`StockTrade.jsx`는 `Stock.css`의 스타일을 공유한다. Stock.css의 반응형 수정은 StockTrade에도 영향을 미치므로, 반드시 두 페이지를 함께 검증해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 구현 순서
|
||||
|
||||
### Phase 1: 글로벌 인프라
|
||||
|
||||
**Phase 1a: Breakpoint 정리 (기존 CSS만 수정, 신규 코드 없음)**
|
||||
1. Breakpoint 시스템 통일 — 각 CSS 파일의 비표준 미디어 쿼리를 표준 값으로 정리
|
||||
2. `index.html`에 `viewport-fit=cover` 추가
|
||||
3. 회귀 테스트: 정리 후 각 페이지 데스크톱/모바일 확인
|
||||
|
||||
**Phase 1b: 공통 컴포넌트 & 앱 셸**
|
||||
4. `react-swipeable` 패키지 설치
|
||||
5. `src/hooks/` 디렉토리 생성 + `useIsMobile`, `useSwipe` 훅 구현
|
||||
6. `BottomNav` 컴포넌트 구현 + 사이드바 모바일 제거 마이그레이션 (Navbar.jsx/css 수정)
|
||||
7. `PullToRefresh`, `SwipeableView`, `FAB`, `MobileSheet` 컴포넌트 구현
|
||||
8. 앱 셸 레이아웃 수정 (App.jsx, App.css)
|
||||
|
||||
### Phase 2: 주요 페이지 적용
|
||||
9. 홈 페이지 반응형 개선
|
||||
10. 로또 페이지 반응형 개선
|
||||
11. 주식 페이지 (Stock + StockTrade 함께 검증) 반응형 개선
|
||||
12. 여행 페이지 반응형 개선
|
||||
|
||||
### Phase 3: 나머지 페이지 확장
|
||||
13. 블로그 (`/blog`) + 블로그 마케팅 (`/blog-lab`)
|
||||
14. 부동산 청약 (`/realestate`)
|
||||
15. 뮤직 스튜디오 (`/music`)
|
||||
16. TODO (`/todo`)
|
||||
17. 에이전트 오피스 (`/agent-office`)
|
||||
18. 이펙트 랩 (`/lab` + `/lab/day-calc` + `/lab/sword-stream`)
|
||||
|
||||
### Phase 4: 검증
|
||||
19. 전체 뷰 모바일 UI 검증 — 대상 뷰포트: 360px (Galaxy S), 390px (iPhone 14), 768px (iPad), 1024px (데스크톱)
|
||||
20. `prefers-reduced-motion` 동작 확인
|
||||
21. 터치 타겟 크기 검증 (44×44px 최소)
|
||||
313
docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md
Normal file
313
docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Travel Gallery Redesign — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI)
|
||||
- **포함**: 동영상 탭 UI 셸 (플레이스홀더)
|
||||
- **제외**: 백엔드 동영상 API (별도 후속 스펙)
|
||||
- **제외**: 핀치 줌 (복잡도 대비 효과 낮음)
|
||||
|
||||
## Architecture
|
||||
|
||||
점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18 (기존)
|
||||
- Leaflet + react-leaflet (기존, 미니맵으로 축소)
|
||||
- react-swipeable (기존, 라이트박스 스와이프)
|
||||
- SwipeableView 컴포넌트 (기존, 사진/영상 탭)
|
||||
- CSS columns (Masonry 레이아웃)
|
||||
- IntersectionObserver (무한스크롤 + 스크롤 리빌)
|
||||
- Web Animations API / CSS transitions (shared element transition)
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Structure & File Layout
|
||||
|
||||
```
|
||||
src/pages/travel/
|
||||
├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트)
|
||||
├── Travel.css # 전체 레이아웃 + CSS 변수
|
||||
├── AlbumCard.jsx # 여행지 앨범 카드
|
||||
├── AlbumCard.css
|
||||
├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry)
|
||||
├── AlbumDetail.css
|
||||
├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤
|
||||
├── MasonryGrid.css
|
||||
├── HeroLightbox.jsx # HERO 확대 전환 라이트박스
|
||||
├── HeroLightbox.css
|
||||
├── MiniMap.jsx # Leaflet 미니맵
|
||||
├── MiniMap.css
|
||||
├── VideoTab.jsx # 영상 탭 UI 셸
|
||||
├── VideoTab.css
|
||||
└── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅
|
||||
```
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 |
|
||||
| `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 |
|
||||
| `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 |
|
||||
| `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 |
|
||||
| `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 |
|
||||
| `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 |
|
||||
| `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 |
|
||||
| `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 |
|
||||
|
||||
### Page Flow
|
||||
|
||||
```
|
||||
Travel.jsx (메인)
|
||||
├── MiniMap (상단, 접기/펼치기 가능)
|
||||
│ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터
|
||||
├── AlbumCard[] (여행지 카드 리스트)
|
||||
│ └── 클릭 → AlbumDetail (오버레이)
|
||||
│ ├── [사진 탭] MasonryGrid
|
||||
│ │ └── 사진 클릭 → HeroLightbox
|
||||
│ └── [영상 탭] VideoTab
|
||||
└── useTravelData (데이터 레이어)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Main View — MiniMap + Album Card List
|
||||
|
||||
### MiniMap
|
||||
|
||||
- 높이: 데스크톱 200px, 모바일 150px
|
||||
- GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출)
|
||||
- 클릭 시 해당 지역 앨범만 필터링
|
||||
- 선택된 지역: 지역별 악센트 컬러로 하이라이트
|
||||
- "전체 보기" 버튼으로 필터 해제
|
||||
- 접기/펼치기 토글 (기본: 펼침)
|
||||
- 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시
|
||||
|
||||
### Album Card List
|
||||
|
||||
- **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지
|
||||
- **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL
|
||||
- **카드 레이아웃**: `display: grid`
|
||||
- 데스크톱 (>1024px): 3열
|
||||
- 태블릿 (769px-1024px): 2열
|
||||
- 모바일 (<=768px): 1열
|
||||
- **카드 높이**: 데스크톱 240px, 모바일 200px
|
||||
- **호버**: scale(1.03) + 지역 악센트 글로우
|
||||
- **지역 필터 전환**: fade 애니메이션 (opacity 300ms)
|
||||
|
||||
### Album Data Grouping
|
||||
|
||||
백엔드 API 변경 없이 프론트에서 처리:
|
||||
|
||||
1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출
|
||||
2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보
|
||||
3. region_map.json의 albums 목록에서 앨범명 추출
|
||||
4. 기존 10분 TTL 캐시 로직 재활용
|
||||
|
||||
---
|
||||
|
||||
## 3. Album Detail — Masonry Grid + Tabs + Transitions
|
||||
|
||||
### Entry Animation (Shared Element Transition)
|
||||
|
||||
1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처
|
||||
2. 카드 clone을 `position: fixed`로 생성
|
||||
3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시
|
||||
|
||||
### Exit Animation
|
||||
|
||||
1. 뒤로가기/닫기 클릭
|
||||
2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms)
|
||||
3. 애니메이션 완료 → 앨범 카드 리스트로 복귀
|
||||
|
||||
### Photo/Video Tabs
|
||||
|
||||
- 앨범 상세 상단에 "사진 | 영상" 탭 바
|
||||
- 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환)
|
||||
- 영상 탭: VideoTab 컴포넌트 (플레이스홀더)
|
||||
|
||||
### Masonry Grid (Photo Tab)
|
||||
|
||||
- **레이아웃**: CSS `column-count` 기반
|
||||
- 데스크톱 (>1024px): 4열
|
||||
- 태블릿 (769px-1024px): 3열
|
||||
- 모바일 (<=768px): 2열
|
||||
- **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`)
|
||||
- **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px`
|
||||
- **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록)
|
||||
- **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20
|
||||
- **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연
|
||||
- **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"`
|
||||
|
||||
### Video Tab (Shell)
|
||||
|
||||
- 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트
|
||||
- 앰버 톤 텍스트, 세리프 폰트
|
||||
- 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체
|
||||
|
||||
---
|
||||
|
||||
## 4. HERO Lightbox
|
||||
|
||||
### Shared Element Transition (Photo → Fullscreen)
|
||||
|
||||
1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처
|
||||
2. 사진 clone을 `position: fixed`로 생성
|
||||
3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시
|
||||
5. 배경은 `#000` opacity 0→1 동시 전환
|
||||
|
||||
### Fullscreen Viewer
|
||||
|
||||
- **배경**: 순수 블랙 `#000`, z-index 3000
|
||||
- **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain`
|
||||
- **좌우 탐색**:
|
||||
- 데스크톱: 좌우 화살표 버튼 (hover 시 표시)
|
||||
- 모바일: react-swipeable로 좌우 스와이프
|
||||
- 키보드: ArrowLeft/ArrowRight
|
||||
- **하단 썸네일 스트립**:
|
||||
- 높이 68px, 썸네일 52x52px
|
||||
- 활성 썸네일: 앰버 테두리 (2px solid)
|
||||
- 활성 썸네일 자동 센터링 (smooth scroll)
|
||||
- 필름 퍼포레이션 장식 제거 (간소화)
|
||||
- **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px)
|
||||
- **닫기**:
|
||||
- X 버튼 (우상단)
|
||||
- 아래로 스와이프 (모바일, threshold 100px)
|
||||
- ESC 키
|
||||
- 닫기 시 역재생 transition → 원래 그리드 위치로 복귀
|
||||
|
||||
### Slide Animation (이전/다음)
|
||||
|
||||
- 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션
|
||||
- 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94)
|
||||
- 방향에 따라 왼쪽/오른쪽에서 진입
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Design — Cinematic Travel Aesthetic
|
||||
|
||||
### Color System
|
||||
|
||||
- **베이스 배경**: `#0f0c09` (깊은 다크)
|
||||
- **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버)
|
||||
- **뮤트 텍스트**: `rgba(245,230,200,0.5)`
|
||||
- **라인/테두리**: `rgba(245,230,200,0.08)`
|
||||
- **지역별 악센트**:
|
||||
- 일본: `#c73e1d` (주홍)
|
||||
- 유럽: `#2563eb` (코발트)
|
||||
- 동남아: `#059669` (에메랄드)
|
||||
- 국내: `#d97706` (호박)
|
||||
- 기타: 기본 앰버 `#d4a574`
|
||||
- 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태
|
||||
|
||||
### Typography
|
||||
|
||||
- **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지)
|
||||
- **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지)
|
||||
- **앨범 카드 제목**: 데스크톱 24px, 모바일 18px
|
||||
- **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트
|
||||
|
||||
### Album Card Visual
|
||||
|
||||
- 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))`
|
||||
- 그라디언트 위에 앨범명 + 사진 수
|
||||
- `border-radius: 12px`
|
||||
- `border: 1px solid rgba(245,230,200,0.08)`
|
||||
- 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)`
|
||||
|
||||
### Masonry Photo Style
|
||||
|
||||
- `border-radius: 4px`
|
||||
- 호버: `filter: brightness(1.08)` + `cursor: zoom-in`
|
||||
- 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연
|
||||
|
||||
### Lightbox Visual
|
||||
|
||||
- 배경: `#000`
|
||||
- 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px
|
||||
- 썸네일 스트립: 활성 아이템에 앰버 2px 테두리
|
||||
- 카운터: "3 / 156" 형태, 우상단, 모노스페이스
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsive Design
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 |
|
||||
|------|----------|-----------|-----------|
|
||||
| >1024px | 3열 | 4열 | 200px |
|
||||
| 769-1024px | 2열 | 3열 | 200px |
|
||||
| <=768px | 1열 | 2열 | 150px |
|
||||
|
||||
### Mobile Specifics
|
||||
|
||||
- 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이)
|
||||
- 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체)
|
||||
- 미니맵: 기본 접힘 (모바일에서 공간 절약)
|
||||
- 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))`
|
||||
|
||||
---
|
||||
|
||||
## 7. Reduced Motion
|
||||
|
||||
`prefers-reduced-motion: reduce` 적용 시:
|
||||
|
||||
- shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms)
|
||||
- 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none)
|
||||
- 카드 호버 scale → 없음 (색상 변화만 유지)
|
||||
- 슬라이드 전환 → 즉시 교체 (fade)
|
||||
- 미니맵 접기/펼치기 → 즉시 전환
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Flow
|
||||
|
||||
```
|
||||
useTravelData hook
|
||||
├── fetchRegions() → GET /api/travel/regions
|
||||
├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region)
|
||||
├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20
|
||||
└── cache (Map, 10min TTL) → 기존 캐시 로직 재활용
|
||||
|
||||
State:
|
||||
- regions: GeoJSON[]
|
||||
- albums: { id, name, region, coverThumb, totalPhotos }[]
|
||||
- selectedRegion: string | null
|
||||
- selectedAlbum: string | null
|
||||
- photos: Photo[]
|
||||
- page, hasNext, loading, loadingMore
|
||||
```
|
||||
|
||||
### API Contract (기존 유지, 변경 없음)
|
||||
|
||||
```
|
||||
GET /api/travel/regions
|
||||
→ GeoJSON FeatureCollection
|
||||
|
||||
GET /api/travel/photos?region=japan&page=1&size=20
|
||||
→ { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] }
|
||||
|
||||
POST /api/travel/reload
|
||||
→ { status: "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance Considerations
|
||||
|
||||
- **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청
|
||||
- **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드
|
||||
- **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드
|
||||
- **lazy loading**: 브라우저 네이티브 `loading="lazy"`
|
||||
- **캐시**: 10분 TTL, 리전 단위
|
||||
- **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시
|
||||
- **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속
|
||||
203
docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
Normal file
203
docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Travel-Proxy 성능 개선 설계
|
||||
|
||||
## 목표
|
||||
|
||||
travel-proxy의 파일 스캔 기반 아키텍처를 SQLite 인덱스 DB로 전환하여 수천 장의 사진을 무난하게 처리하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 travel-proxy는 `os.scandir`으로 NAS 폴더를 매번 스캔하고, 메모리 캐시(TTL 300초)로 결과를 보관한다. 사진 수백 장에서는 문제없지만, 수천 장이면:
|
||||
- 캐시 만료 시 1~2초 스캔 지연
|
||||
- 콜드 스타트(컨테이너 재시작) 시 첫 요청 느림
|
||||
- 전체 리스트를 메모리에 상주
|
||||
- 썸네일이 첫 요청 시 동기 생성되어 초기 로딩 지연
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 변경 전
|
||||
|
||||
```
|
||||
API 요청 → os.scandir(폴더) → 메모리 캐시 → 슬라이싱 페이지네이션
|
||||
↓
|
||||
썸네일 온디맨드 생성 (Pillow)
|
||||
```
|
||||
|
||||
### 변경 후
|
||||
|
||||
```
|
||||
수동 sync 버튼 → 폴더 스캔 → travel.db 동기화 + 썸네일 사전 생성
|
||||
↓
|
||||
API 요청 → SQLite 쿼리 (인덱스) → 페이지네이션
|
||||
```
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `main.py` | FastAPI 라우트 (기존 + 신규) |
|
||||
| `db.py` (신규) | SQLite 스키마 정의, 쿼리 헬퍼 |
|
||||
| `indexer.py` (신규) | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
|
||||
기존 `main.py`의 `scan_album`, `ensure_thumb`, 메모리 캐시 로직이 `indexer.py`와 `db.py`로 이동하고, `main.py`는 라우트만 남는다.
|
||||
|
||||
## DB 스키마
|
||||
|
||||
```sql
|
||||
CREATE TABLE photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
album TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
has_thumb INTEGER DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL,
|
||||
UNIQUE(album, filename)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_photos_album ON photos(album);
|
||||
|
||||
CREATE TABLE album_covers (
|
||||
album TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 설계 포인트
|
||||
|
||||
- `photos` 테이블에 URL/thumb 경로를 저장하지 않음 — 런타임에 `MEDIA_BASE` + album + filename으로 조합 (환경변수 변경에 유연)
|
||||
- `mtime`으로 변경 감지 — 동기화 시 파일이 삭제됐거나 mtime이 바뀌면 갱신
|
||||
- `album_covers`가 비어있으면 해당 앨범의 첫 번째 사진이 자동 커버
|
||||
|
||||
## API 설계
|
||||
|
||||
### 기존 API 변경
|
||||
|
||||
| 엔드포인트 | 변경 내용 |
|
||||
|-----------|----------|
|
||||
| `GET /api/travel/photos` | 내부 로직만 변경 (os.scandir → DB 쿼리). 응답 형식 동일 |
|
||||
| `GET /api/travel/regions` | 변경 없음 |
|
||||
| `POST /api/travel/reload` | 제거 (sync로 대체) |
|
||||
| `GET /media/travel/.thumb/{album}/{filename}` | 유지 — 동기화 시 이미 썸네일 생성되므로 Pillow 호출 빈도 대폭 감소. 미생성 분 폴백으로 온디맨드 생성 유지 |
|
||||
|
||||
### 신규 API
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| `POST` | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| `GET` | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| `PUT` | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
|
||||
### POST /api/travel/sync
|
||||
|
||||
폴더를 스캔하여 DB와 동기화하고, 미생성 썸네일을 일괄 생성한다.
|
||||
|
||||
**요청**: 바디 없음
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
{
|
||||
"added": 42,
|
||||
"removed": 3,
|
||||
"thumbs_generated": 42,
|
||||
"duration_sec": 12.5
|
||||
}
|
||||
```
|
||||
|
||||
**동기 실행** — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
|
||||
|
||||
### GET /api/travel/albums
|
||||
|
||||
앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"album": "오사카",
|
||||
"count": 342,
|
||||
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
|
||||
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
커버가 지정되지 않은 앨범은 첫 번째 사진(album + filename 정렬 기준)이 자동 커버.
|
||||
|
||||
### PUT /api/travel/albums/{album}/cover
|
||||
|
||||
특정 사진을 앨범 커버로 지정한다.
|
||||
|
||||
**요청**:
|
||||
```json
|
||||
{
|
||||
"filename": "IMG_3281.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
{
|
||||
"album": "오사카",
|
||||
"filename": "IMG_3281.jpg",
|
||||
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
|
||||
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
**검증**: 해당 album + filename 조합이 photos 테이블에 존재하는지 확인. 없으면 404.
|
||||
|
||||
## 동기화 로직 (indexer.py)
|
||||
|
||||
### sync 프로세스
|
||||
|
||||
1. `region_map.json`에서 전체 앨범 폴더 목록 수집
|
||||
2. 각 폴더 `os.scandir` → `{album, filename, mtime}` 세트 수집
|
||||
3. DB와 비교:
|
||||
- DB에 없는 파일 → INSERT (`added`)
|
||||
- DB에 있지만 폴더에 없는 파일 → DELETE (`removed`)
|
||||
- mtime이 다른 파일 → UPDATE + `has_thumb=0` (변경됨)
|
||||
4. `has_thumb=0`인 파일 → 썸네일 생성 → `has_thumb=1`로 갱신
|
||||
5. 결과 반환: `{added, removed, thumbs_generated, duration_sec}`
|
||||
|
||||
### 삭제된 커버 처리
|
||||
|
||||
커버로 지정된 사진이 폴더에서 삭제되면 `album_covers`에서도 제거 → 자동으로 첫 번째 사진 폴백.
|
||||
|
||||
### 성능
|
||||
|
||||
- NAS Celeron J4025 기준, 2,000장 최초 동기화 + 썸네일 생성 예상: 3~5분
|
||||
- 이후 동기화는 변경분만 처리 → 수초 이내
|
||||
|
||||
## 앨범 커버 지정 UX
|
||||
|
||||
프론트엔드 앨범 상세 페이지에서 사진을 길게 누르거나 우클릭 → "커버로 설정" 메뉴. `PUT /api/travel/albums/{album}/cover` 호출.
|
||||
|
||||
프론트엔드 변경은 이 스펙 범위 밖 — 백엔드 API만 제공하고, 프론트 연동은 별도 작업.
|
||||
|
||||
## 기존 API 호환성
|
||||
|
||||
- `GET /api/travel/photos` 응답 형식 (`items`, `total`, `has_next`, `matched_albums`) 완전히 유지
|
||||
- 프론트엔드 `useTravelData` 훅은 수정 없이 동작
|
||||
- `GET /api/travel/albums`는 선택적 개선용 — 프론트가 앨범 카드 커버를 표시할 때 활용
|
||||
|
||||
## Docker 변경
|
||||
|
||||
- `travel.db` 저장 위치: 썸네일 볼륨 내 `/data/thumbs/travel.db` (추가 볼륨 불필요)
|
||||
- `requirements.txt`에 `aiosqlite` 추가 불필요 — 동기 sqlite3 표준 라이브러리 사용
|
||||
- Dockerfile 변경 없음
|
||||
|
||||
### docker-compose.yml 변경
|
||||
|
||||
기존 볼륨에 DB를 함께 저장하므로 추가 볼륨 불필요:
|
||||
```yaml
|
||||
volumes:
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}\travel-thumbs:/data/thumbs:rw # travel.db도 여기에 저장
|
||||
```
|
||||
|
||||
## 제거되는 코드
|
||||
|
||||
- `main.py`의 `CACHE`, `CACHE_TTL`, `META_MTIME_CACHE` 딕셔너리 및 관련 로직
|
||||
- `main.py`의 `scan_album()` 함수 (indexer.py로 이동)
|
||||
- `main.py`의 `ensure_thumb()` 함수 (indexer.py로 이동, 온디맨드 폴백은 유지)
|
||||
- `POST /api/travel/reload` 엔드포인트 (sync로 대체)
|
||||
@@ -91,6 +91,7 @@ server {
|
||||
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_read_timeout 600s;
|
||||
proxy_pass http://travel-proxy:8000/api/travel/;
|
||||
}
|
||||
|
||||
|
||||
0
travel-proxy/app/__init__.py
Normal file
0
travel-proxy/app/__init__.py
Normal file
233
travel-proxy/app/db.py
Normal file
233
travel-proxy/app/db.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
album TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
has_thumb INTEGER DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(album, filename)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS album_covers (
|
||||
album TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
|
||||
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
|
||||
if not albums:
|
||||
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
|
||||
|
||||
placeholders = ",".join("?" for _ in albums)
|
||||
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
|
||||
albums,
|
||||
).fetchall()
|
||||
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
|
||||
total = sum(r["cnt"] for r in rows)
|
||||
|
||||
offset = (page - 1) * size
|
||||
items = conn.execute(
|
||||
f"""SELECT album, filename, mtime FROM photos
|
||||
WHERE album IN ({placeholders})
|
||||
ORDER BY album, filename
|
||||
LIMIT ? OFFSET ?""",
|
||||
[*albums, size, offset],
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(r) for r in items],
|
||||
"total": total,
|
||||
"has_next": (offset + size) < total,
|
||||
"matched_albums": matched_albums,
|
||||
}
|
||||
|
||||
|
||||
def get_all_albums() -> List[Dict[str, Any]]:
|
||||
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT p.album, COUNT(*) as count,
|
||||
COALESCE(c.filename, MIN(p.filename)) as cover_filename
|
||||
FROM photos p
|
||||
LEFT JOIN album_covers c ON p.album = c.album
|
||||
GROUP BY p.album
|
||||
ORDER BY p.album
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_album_cover(album: str, filename: str) -> bool:
|
||||
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
|
||||
with _conn() as conn:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return False
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO album_covers (album, filename, updated_at)
|
||||
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(album) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
updated_at = excluded.updated_at""",
|
||||
(album, filename),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_album_cover(album: str) -> Optional[str]:
|
||||
"""앨범 커버 파일명 반환. 미지정 시 None."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM album_covers WHERE album = ?",
|
||||
(album,),
|
||||
).fetchone()
|
||||
return row["filename"] if row else None
|
||||
|
||||
|
||||
def upsert_photo(album: str, filename: str, mtime: float) -> str:
|
||||
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
|
||||
with _conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||
(album, filename, mtime),
|
||||
)
|
||||
return "added"
|
||||
elif existing["mtime"] != mtime:
|
||||
conn.execute(
|
||||
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||
(mtime, album, filename),
|
||||
)
|
||||
return "updated"
|
||||
return "unchanged"
|
||||
|
||||
|
||||
def remove_missing_photos(album: str, existing_filenames: Set[str]) -> int:
|
||||
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
|
||||
with _conn() as conn:
|
||||
db_rows = conn.execute(
|
||||
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||
).fetchall()
|
||||
db_filenames = {r["filename"] for r in db_rows}
|
||||
to_remove = db_filenames - existing_filenames
|
||||
|
||||
if to_remove:
|
||||
placeholders = ",".join("?" for _ in to_remove)
|
||||
conn.execute(
|
||||
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
return len(to_remove)
|
||||
|
||||
|
||||
def get_photos_without_thumb() -> List[Dict[str, str]]:
|
||||
"""썸네일 미생성 사진 목록."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT album, filename FROM photos WHERE has_thumb = 0"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_thumb_done(album: str, filename: str) -> None:
|
||||
"""썸네일 생성 완료 표시."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
)
|
||||
|
||||
|
||||
def batch_sync_album(album: str, items: List[Dict[str, Any]], existing_filenames: Set[str]) -> Dict[str, int]:
|
||||
"""앨범 단위 배치 동기화. 단일 커넥션으로 upsert + 삭제 처리."""
|
||||
added = updated = 0
|
||||
with _conn() as conn:
|
||||
for item in items:
|
||||
existing = conn.execute(
|
||||
"SELECT mtime FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, item["filename"]),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||
(album, item["filename"], item["mtime"]),
|
||||
)
|
||||
added += 1
|
||||
elif existing["mtime"] != item["mtime"]:
|
||||
conn.execute(
|
||||
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||
(item["mtime"], album, item["filename"]),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
# 삭제 처리
|
||||
db_rows = conn.execute(
|
||||
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||
).fetchall()
|
||||
db_filenames = {r["filename"] for r in db_rows}
|
||||
to_remove = db_filenames - existing_filenames
|
||||
|
||||
removed = len(to_remove)
|
||||
if to_remove:
|
||||
placeholders = ",".join("?" for _ in to_remove)
|
||||
conn.execute(
|
||||
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
|
||||
return {"added": added, "updated": updated, "removed": removed}
|
||||
|
||||
|
||||
def batch_mark_thumbs_done(items: List[Dict[str, str]]) -> None:
|
||||
"""썸네일 생성 완료 배치 표시."""
|
||||
if not items:
|
||||
return
|
||||
with _conn() as conn:
|
||||
for item in items:
|
||||
conn.execute(
|
||||
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||
(item["album"], item["filename"]),
|
||||
)
|
||||
136
travel-proxy/app/indexer.py
Normal file
136
travel-proxy/app/indexer.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
|
||||
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
|
||||
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
|
||||
if not folder.exists():
|
||||
return []
|
||||
items = []
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||
try:
|
||||
mtime = entry.stat().st_mtime
|
||||
except OSError as e:
|
||||
logger.warning("Cannot stat %s: %s", entry.path, e)
|
||||
continue
|
||||
items.append({
|
||||
"filename": entry.name,
|
||||
"mtime": mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _generate_thumb(src: Path, dest: Path) -> bool:
|
||||
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = dest.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(dest)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Thumb generation failed: %s → %s", src, e)
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def sync(
|
||||
travel_root: Path,
|
||||
thumb_root: Path,
|
||||
region_map_path: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||
|
||||
Returns:
|
||||
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# 1. region_map.json에서 전체 앨범 폴더 수집
|
||||
try:
|
||||
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||
region_map = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
logger.error("Failed to load region_map: %s", e)
|
||||
raise
|
||||
|
||||
all_albums: Set[str] = set()
|
||||
for v in region_map.values():
|
||||
if isinstance(v, list):
|
||||
all_albums.update(v)
|
||||
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
all_albums.update(v["albums"])
|
||||
|
||||
# 2. 각 앨범 폴더 스캔 → DB 배치 동기화
|
||||
added = 0
|
||||
updated = 0
|
||||
removed = 0
|
||||
|
||||
for album in sorted(all_albums):
|
||||
folder = travel_root / album
|
||||
items = _scan_folder(folder)
|
||||
existing_filenames = {item["filename"] for item in items}
|
||||
|
||||
result = db.batch_sync_album(album, items, existing_filenames)
|
||||
added += result["added"]
|
||||
updated += result["updated"]
|
||||
removed += result["removed"]
|
||||
|
||||
# 3. 썸네일 미생성 분 일괄 생성
|
||||
no_thumb = db.get_photos_without_thumb()
|
||||
thumbs_generated = 0
|
||||
thumb_done_batch = []
|
||||
|
||||
for photo in no_thumb:
|
||||
src = travel_root / photo["album"] / photo["filename"]
|
||||
dest = thumb_root / photo["album"] / photo["filename"]
|
||||
if _generate_thumb(src, dest):
|
||||
thumb_done_batch.append(photo)
|
||||
thumbs_generated += 1
|
||||
|
||||
db.batch_mark_thumbs_done(thumb_done_batch)
|
||||
|
||||
duration = round(time.time() - start, 2)
|
||||
logger.info(
|
||||
"Sync complete: added=%d updated=%d removed=%d thumbs=%d duration=%.2fs",
|
||||
added, updated, removed, thumbs_generated, duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"added": added,
|
||||
"updated": updated,
|
||||
"removed": removed,
|
||||
"thumbs_generated": thumbs_generated,
|
||||
"duration_sec": duration,
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from PIL import Image
|
||||
|
||||
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, mark_thumb_done
|
||||
from .indexer import sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# -----------------------------
|
||||
@@ -26,32 +31,16 @@ THUMB_SIZE = (480, 480)
|
||||
|
||||
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 썸네일 정적 서빙
|
||||
app.mount(
|
||||
f"{MEDIA_BASE}/.thumb",
|
||||
StaticFiles(directory=THUMB_ROOT),
|
||||
name="travel-thumbs",
|
||||
)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
# -----------------------------
|
||||
# Cache
|
||||
# DB init
|
||||
# -----------------------------
|
||||
CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
CACHE_TTL = int(os.getenv("TRAVEL_CACHE_TTL", "300"))
|
||||
META_MTIME_CACHE: Dict[str, float] = {}
|
||||
init_db()
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
def _file_mtime(p: Path) -> float:
|
||||
try:
|
||||
return p.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
raise HTTPException(500, f"Missing required file: {path}")
|
||||
@@ -67,57 +56,27 @@ def load_regions_geojson() -> dict:
|
||||
return _read_json(REGIONS_GEOJSON_PATH)
|
||||
|
||||
|
||||
def _meta_changed_invalidate_cache():
|
||||
cur = _file_mtime(REGION_MAP_PATH) + _file_mtime(REGIONS_GEOJSON_PATH)
|
||||
if META_MTIME_CACHE.get("meta") != cur:
|
||||
CACHE.clear()
|
||||
META_MTIME_CACHE["meta"] = cur
|
||||
|
||||
|
||||
def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
|
||||
if region not in region_map:
|
||||
raise HTTPException(400, "Unknown region")
|
||||
|
||||
v = region_map[region]
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
return v["albums"]
|
||||
|
||||
raise HTTPException(500, "Invalid region_map format")
|
||||
|
||||
|
||||
def _thumb_path_for(src: Path, album: str) -> Path:
|
||||
"""
|
||||
썸네일 저장 위치 결정.
|
||||
- TRAVEL_THUMB_ROOT가 있으면: THUMB_ROOT/album/<filename>
|
||||
- 없으면: 원본 폴더 album/.thumb/<filename>
|
||||
"""
|
||||
if THUMB_ROOT:
|
||||
base = THUMB_ROOT / album
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / src.name
|
||||
|
||||
thumb_dir = src.parent / ".thumb"
|
||||
thumb_dir.mkdir(exist_ok=True)
|
||||
return thumb_dir / src.name
|
||||
|
||||
def ensure_thumb(src: Path, album: str) -> Path:
|
||||
out = _thumb_path_for(src, album) # THUMB_ROOT/album/<filename> or album/.thumb/<filename>
|
||||
|
||||
def _ensure_thumb_fallback(src: Path, album: str) -> Path:
|
||||
"""온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
|
||||
out = THUMB_ROOT / album / src.name
|
||||
if out.exists():
|
||||
return out
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ✅ 확장자 유지: IMG_3281.tmp.JPG (끝이 .JPG로 끝나게)
|
||||
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
|
||||
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
|
||||
# ✅ 확장자 기준으로 포맷 명시 (대문자 .JPG도 대응)
|
||||
ext = out.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
@@ -126,44 +85,25 @@ def ensure_thumb(src: Path, album: str) -> Path:
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
# 혹시 모를 경우: Pillow가 읽은 포맷을 사용
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
|
||||
# ✅ 생성 완료 후 교체
|
||||
tmp.replace(out)
|
||||
mark_thumb_done(album, src.name)
|
||||
return out
|
||||
|
||||
finally:
|
||||
# 실패 시 tmp 찌꺼기 정리
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def scan_album(album: str) -> List[Dict[str, Any]]:
|
||||
album_dir = ROOT / album
|
||||
if not album_dir.exists():
|
||||
return []
|
||||
|
||||
items = []
|
||||
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴)
|
||||
with os.scandir(album_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
|
||||
# 파일 존재 여부만 확인하고 바로 리턴
|
||||
items.append({
|
||||
"album": album,
|
||||
"file": entry.name,
|
||||
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
|
||||
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
|
||||
"mtime": entry.stat().st_mtime
|
||||
})
|
||||
return items
|
||||
# -----------------------------
|
||||
# Models
|
||||
# -----------------------------
|
||||
class CoverRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Routes
|
||||
@@ -172,18 +112,11 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
|
||||
def health():
|
||||
return {"status": "healthy", "service": "travel-proxy"}
|
||||
|
||||
|
||||
@app.get("/api/travel/regions")
|
||||
def regions():
|
||||
_meta_changed_invalidate_cache()
|
||||
return load_regions_geojson()
|
||||
|
||||
@app.post("/api/travel/reload")
|
||||
def reload_cache():
|
||||
"""강제로 캐시를 비워서 새로고침"""
|
||||
CACHE.clear()
|
||||
META_MTIME_CACHE.clear()
|
||||
return {"ok": True, "message": "Cache cleared"}
|
||||
|
||||
|
||||
@app.get("/api/travel/photos")
|
||||
def photos(
|
||||
@@ -191,53 +124,67 @@ def photos(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
_meta_changed_invalidate_cache()
|
||||
region_map = load_region_map()
|
||||
albums = _get_albums_for_region(region, region_map)
|
||||
result = get_photos_by_region(albums, page, size)
|
||||
|
||||
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략)
|
||||
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문.
|
||||
now = time.time()
|
||||
cached = CACHE.get(region)
|
||||
|
||||
if not cached or now - cached["ts"] >= CACHE_TTL:
|
||||
region_map = load_region_map()
|
||||
albums = _get_albums_for_region(region, region_map)
|
||||
|
||||
all_items = []
|
||||
matched = []
|
||||
|
||||
for album in albums:
|
||||
items = scan_album(album)
|
||||
matched.append({"album": album, "count": len(items)})
|
||||
all_items.extend(items)
|
||||
|
||||
# 정렬: 앨범명 > 파일명 (또는 찍은 날짜)
|
||||
all_items.sort(key=lambda x: (x["album"], x["file"]))
|
||||
|
||||
cached_data = {
|
||||
"region": region,
|
||||
"matched_albums": matched,
|
||||
"items": all_items,
|
||||
"total": len(all_items),
|
||||
}
|
||||
CACHE[region] = {"ts": now, "data": cached_data}
|
||||
else:
|
||||
cached_data = cached["data"]
|
||||
|
||||
# 2. 페이지네이션 슬라이싱
|
||||
all_items = cached_data["items"]
|
||||
total = len(all_items)
|
||||
start = (page - 1) * size
|
||||
end = start + size
|
||||
|
||||
paged_items = all_items[start:end]
|
||||
# URL 조합 (DB에는 경로를 저장하지 않음)
|
||||
items = []
|
||||
for row in result["items"]:
|
||||
items.append({
|
||||
"album": row["album"],
|
||||
"file": row["filename"],
|
||||
"url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
|
||||
"mtime": row["mtime"],
|
||||
})
|
||||
|
||||
return {
|
||||
"region": region,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total": total,
|
||||
"has_next": end < total,
|
||||
"items": paged_items,
|
||||
"total": result["total"],
|
||||
"has_next": result["has_next"],
|
||||
"items": items,
|
||||
"matched_albums": result["matched_albums"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/travel/sync")
|
||||
def sync_endpoint():
|
||||
result = sync(
|
||||
travel_root=ROOT,
|
||||
thumb_root=THUMB_ROOT,
|
||||
region_map_path=REGION_MAP_PATH,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/travel/albums")
|
||||
def albums_list():
|
||||
rows = get_all_albums()
|
||||
result = []
|
||||
for r in rows:
|
||||
cover = r["cover_filename"]
|
||||
result.append({
|
||||
"album": r["album"],
|
||||
"count": r["count"],
|
||||
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/travel/albums/{album}/cover")
|
||||
def set_cover(album: str, body: CoverRequest):
|
||||
ok = set_album_cover(album, body.filename)
|
||||
if not ok:
|
||||
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
|
||||
return {
|
||||
"album": album,
|
||||
"filename": body.filename,
|
||||
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
|
||||
}
|
||||
|
||||
|
||||
@@ -245,19 +192,17 @@ def photos(
|
||||
def get_thumb(album: str, filename: str):
|
||||
if ".." in album or ".." in filename:
|
||||
raise HTTPException(400, "Invalid path")
|
||||
|
||||
src = (ROOT / album / filename).resolve()
|
||||
if not str(src).startswith(str(ROOT)):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if not src.exists() or not src.is_file():
|
||||
raise HTTPException(404, "Source not found")
|
||||
|
||||
p = ensure_thumb(src, album)
|
||||
p = _ensure_thumb_fallback(src, album)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
return FileResponse(str(p))
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
import os
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
|
||||
Reference in New Issue
Block a user