feat: v3.1 대시보드 추가 (React + FastAPI)

Media Engine Control Panel — 6탭 웹 대시보드

[백엔드] FastAPI (dashboard/backend/)
- server.py: 포트 8080, CORS, React SPA 서빙
- api_overview.py: KPI 카드 + 파이프라인 상태 + 활동 로그
- api_content.py: 칸반 보드 + 승인/거부 + 수동 트리거
- api_analytics.py: 방문자 추이 + 플랫폼/코너별 성과
- api_novels.py: 소설 목록/생성/에피소드 관리
- api_settings.py: engine.json CRUD
- api_connections.py: AI 서비스 연결 관리 + 키 저장
- api_tools.py: 기능별 AI 도구 선택
- api_cost.py: 구독 현황 + API 사용량 추적
- api_logs.py: 시스템 로그 필터/검색

[프론트엔드] React + Vite + Tailwind + Recharts (dashboard/frontend/)
- Overview: KPI 카드 + 파이프라인 + 코너별 바차트 + 활동 로그
- Content: 4열 칸반 보드 + 상세 모달 + 승인/거부
- Analytics: LineChart 방문자 추이 + 플랫폼별 성과
- Novel: 소설 목록 + 에피소드 테이블 + 새 소설 생성 폼
- Settings: 5개 서브탭 (AI연결/도구선택/배포채널/품질/비용관리)
- Logs: 필터/검색 시스템 로그 뷰어

[디자인] CNN 다크+골드 테마
- 배경 #0a0a0d + 액센트 #c8a84e
- 모바일 반응형 (Tailscale 외부 접속 대응)

[실행]
- dashboard/start.bat 더블클릭 → http://localhost:8080

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-26 13:17:53 +09:00
parent 8a7a122bb3
commit 213f57b52d
35 changed files with 3971 additions and 0 deletions

122
dashboard/README.md Normal file
View File

@@ -0,0 +1,122 @@
# The 4th Path — Control Panel
미디어 엔진 컨트롤 패널 (React + FastAPI)
## 구조
```
dashboard/
├── backend/
│ ├── server.py # FastAPI 메인
│ ├── api_overview.py # 개요 탭 API
│ ├── api_content.py # 콘텐츠 탭 API
│ ├── api_analytics.py # 분석 탭 API
│ ├── api_novels.py # 소설 탭 API
│ ├── api_settings.py # 설정 API
│ ├── api_connections.py# 연결 상태 API
│ ├── api_tools.py # 도구 선택 API
│ ├── api_cost.py # 비용 모니터 API
│ └── api_logs.py # 로그 API
└── frontend/
├── src/
│ ├── App.jsx
│ ├── pages/
│ │ ├── Overview.jsx
│ │ ├── Content.jsx
│ │ ├── Analytics.jsx
│ │ ├── Novel.jsx
│ │ ├── Settings.jsx
│ │ ├── Logs.jsx
│ │ └── settings/
│ │ ├── Connections.jsx
│ │ ├── ToolSelect.jsx
│ │ ├── Distribution.jsx
│ │ ├── Quality.jsx
│ │ └── CostMonitor.jsx
│ └── styles/
│ └── theme.css
├── package.json
├── vite.config.js
└── tailwind.config.js
```
## 설치 및 실행
### 필수 요건
- Python 3.9+
- Node.js 18+
- npm 9+
### 백엔드 의존성 설치
```bash
cd D:/workspace/blog-writer
pip install fastapi uvicorn python-dotenv
```
### 프론트엔드 의존성 설치
```bash
cd D:/workspace/blog-writer/dashboard/frontend
npm install
```
## 실행 방법
### Windows (더블클릭)
- **프로덕션**: `start.bat` 더블클릭
- **개발 모드**: `start_dev.bat` 더블클릭
### Linux/Mac
```bash
# 프로덕션 (프론트 빌드 후 백엔드만)
bash dashboard/start.sh
# 개발 모드 (Vite 핫리로드 + 백엔드 reload)
bash dashboard/start.sh dev
```
### 수동 실행
```bash
# 터미널 1 — 백엔드
cd D:/workspace/blog-writer
python -m uvicorn dashboard.backend.server:app --port 8080 --reload
# 터미널 2 — 프론트엔드 (개발)
cd D:/workspace/blog-writer/dashboard/frontend
npm run dev
# 또는 프론트 빌드 (프로덕션)
npm run build
```
## 접속
| 모드 | URL |
|------|-----|
| 프로덕션 | http://localhost:8080 |
| 개발(프론트) | http://localhost:5173 |
| API 문서 | http://localhost:8080/docs |
## 탭 구성
| 탭 | 기능 |
|----|------|
| 개요 | KPI 카드 · 파이프라인 상태 · 코너별 비율 · 활동 로그 |
| 콘텐츠 | 칸반 보드 · 승인/거부 |
| 분석 | 방문자 추이 · 코너별 성과 · 인기글 |
| 소설 | 연재 관리 · 에피소드 생성 |
| 설정 | AI 연결 · 도구 선택 · 배포채널 · 품질 · 비용 |
| 로그 | 시스템 로그 필터/검색 |
## Tailscale 외부 접속
```bash
# 백엔드를 0.0.0.0으로 바인딩하면 Tailscale IP로 접속 가능
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
# 접속: http://<tailscale-ip>:8080
```

1
dashboard/__init__.py Normal file
View File

@@ -0,0 +1 @@
# dashboard package

View File

@@ -0,0 +1 @@
# dashboard/backend package

View File

@@ -0,0 +1,131 @@
"""
dashboard/backend/api_analytics.py
Analytics 탭 API — 방문자 통계, KPI, 코너별 성과
"""
import json
from datetime import date, timedelta
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Query
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = BASE_DIR / "data"
ANALYTICS_DIR = DATA_DIR / "analytics"
router = APIRouter()
def _load_all_analytics() -> list:
"""analytics/*.json 전체 로드"""
records = []
if not ANALYTICS_DIR.exists():
return records
for f in sorted(ANALYTICS_DIR.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
if isinstance(data, list):
records.extend(data)
elif isinstance(data, dict):
records.append(data)
except Exception:
pass
return records
def _aggregate_kpi(records: list) -> dict:
total_visitors = sum(r.get("visitors", 0) for r in records)
total_pageviews = sum(r.get("pageviews", 0) for r in records)
avg_duration = 0
avg_ctr = 0.0
durations = [r.get("avg_duration_sec", 0) for r in records if r.get("avg_duration_sec")]
if durations:
avg_duration = int(sum(durations) / len(durations))
ctrs = [r.get("ctr", 0.0) for r in records if r.get("ctr")]
if ctrs:
avg_ctr = round(sum(ctrs) / len(ctrs), 2)
return {
"visitors": total_visitors,
"pageviews": total_pageviews,
"avg_duration_sec": avg_duration,
"ctr": avg_ctr,
}
def _aggregate_corners(records: list) -> list:
corner_map: dict = {}
for r in records:
corner = r.get("corner", "기타")
if corner not in corner_map:
corner_map[corner] = {"visitors": 0, "pageviews": 0, "posts": 0}
corner_map[corner]["visitors"] += r.get("visitors", 0)
corner_map[corner]["pageviews"] += r.get("pageviews", 0)
corner_map[corner]["posts"] += r.get("post_count", 1)
result = []
for name, data in corner_map.items():
result.append({"corner": name, **data})
result.sort(key=lambda x: x["visitors"], reverse=True)
return result
def _top_posts(records: list, limit: int = 5) -> list:
posts = []
for r in records:
if "title" in r and "visitors" in r:
posts.append({
"title": r["title"],
"visitors": r["visitors"],
"corner": r.get("corner", ""),
"published_at": r.get("date", ""),
})
posts.sort(key=lambda x: x["visitors"], reverse=True)
return posts[:limit]
def _platform_performance(records: list) -> list:
platform_map: dict = {}
for r in records:
platform = r.get("platform", "blogger")
if platform not in platform_map:
platform_map[platform] = {"visitors": 0, "posts": 0}
platform_map[platform]["visitors"] += r.get("visitors", 0)
platform_map[platform]["posts"] += 1
return [{"platform": k, **v} for k, v in platform_map.items()]
@router.get("/analytics")
async def get_analytics():
records = _load_all_analytics()
return {
"kpi": _aggregate_kpi(records),
"corners": _aggregate_corners(records),
"top_posts": _top_posts(records),
"platforms": _platform_performance(records),
"total_records": len(records),
}
@router.get("/analytics/chart")
async def get_analytics_chart(days: int = Query(default=7, ge=1, le=365)):
"""days일간 방문자 시계열 데이터"""
records = _load_all_analytics()
today = date.today()
date_range = [(today - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]
# 날짜별 집계
daily: dict = {d: {"date": d, "visitors": 0, "pageviews": 0} for d in date_range}
for r in records:
d = r.get("date", "")[:10]
if d in daily:
daily[d]["visitors"] += r.get("visitors", 0)
daily[d]["pageviews"] += r.get("pageviews", 0)
return {"chart": list(daily.values()), "days": days}

View File

@@ -0,0 +1,207 @@
"""
dashboard/backend/api_connections.py
Settings > Connections 탭 API — AI 서비스 연결 상태 확인/테스트
"""
import json
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
ENV_PATH = BASE_DIR / ".env"
router = APIRouter()
AI_SERVICES = [
{
"id": "claude",
"name": "Claude (Anthropic)",
"env_key": "ANTHROPIC_API_KEY",
"category": "writing",
"description": "글쓰기 엔진 — claude-opus-4-5",
},
{
"id": "gemini",
"name": "Google Gemini",
"env_key": "GEMINI_API_KEY",
"category": "writing",
"description": "글쓰기 엔진 — gemini-2.0-flash",
},
{
"id": "openai",
"name": "OpenAI (GPT + DALL-E + TTS)",
"env_key": "OPENAI_API_KEY",
"category": "multi",
"description": "이미지(DALL-E 3) + TTS(tts-1-hd)",
},
{
"id": "elevenlabs",
"name": "ElevenLabs TTS",
"env_key": "ELEVENLABS_API_KEY",
"category": "tts",
"description": "고품질 한국어 TTS",
},
{
"id": "google_tts",
"name": "Google Cloud TTS",
"env_key": "GOOGLE_TTS_API_KEY",
"category": "tts",
"description": "Google Wavenet TTS",
},
{
"id": "seedance",
"name": "Seedance AI Video",
"env_key": "SEEDANCE_API_KEY",
"category": "video",
"description": "AI 영상 생성 — Seedance 2.0",
},
{
"id": "runway",
"name": "Runway Gen-3",
"env_key": "RUNWAY_API_KEY",
"category": "video",
"description": "AI 영상 생성 — Gen-3 Turbo",
},
]
class ApiKeyUpdate(BaseModel):
api_key: str
def _mask_key(key: str) -> str:
if not key:
return ""
if len(key) <= 8:
return "****"
return key[:4] + "****" + key[-4:]
def _get_connections():
connections = []
for svc in AI_SERVICES:
key = os.getenv(svc["env_key"], "")
connections.append({
**svc,
"connected": bool(key),
"key_masked": _mask_key(key),
})
return connections
@router.get("/connections")
async def get_connections():
return {"connections": _get_connections()}
@router.post("/connections/{service_id}/test")
async def test_connection(service_id: str):
"""서비스 연결 테스트"""
svc = next((s for s in AI_SERVICES if s["id"] == service_id), None)
if not svc:
raise HTTPException(status_code=404, detail="서비스를 찾을 수 없습니다.")
api_key = os.getenv(svc["env_key"], "")
if not api_key:
return {"success": False, "message": "API 키가 설정되지 않았습니다."}
# 간단한 연결 테스트
try:
if service_id == "claude":
import anthropic
client = anthropic.Anthropic(api_key=api_key)
# 모델 목록으로 연결 테스트
client.messages.create(
model="claude-haiku-4-5",
max_tokens=10,
messages=[{"role": "user", "content": "ping"}],
)
return {"success": True, "message": "Claude 연결 성공"}
elif service_id == "openai":
from openai import OpenAI
client = OpenAI(api_key=api_key)
client.models.list()
return {"success": True, "message": "OpenAI 연결 성공"}
elif service_id == "gemini":
import google.generativeai as genai
genai.configure(api_key=api_key)
model = genai.GenerativeModel("gemini-2.0-flash")
model.generate_content("ping", generation_config={"max_output_tokens": 5})
return {"success": True, "message": "Gemini 연결 성공"}
elif service_id in ("elevenlabs", "seedance", "runway", "google_tts"):
import requests
test_urls = {
"elevenlabs": "https://api.elevenlabs.io/v1/models",
"google_tts": f"https://texttospeech.googleapis.com/v1/voices?key={api_key}",
"seedance": "https://api.seedance2.ai/v1/models",
"runway": "https://api.runwayml.com/v1/organization",
}
headers_map = {
"elevenlabs": {"xi-api-key": api_key},
"runway": {"Authorization": f"Bearer {api_key}"},
}
url = test_urls.get(service_id, "")
headers = headers_map.get(service_id, {})
if url:
resp = requests.get(url, headers=headers, timeout=10)
if resp.status_code < 400:
return {"success": True, "message": f"{svc['name']} 연결 성공"}
else:
return {"success": False, "message": f"HTTP {resp.status_code}"}
return {"success": True, "message": "키 존재 확인됨 (심층 테스트 미지원)"}
except ImportError as e:
return {"success": False, "message": f"라이브러리 미설치: {e}"}
except Exception as e:
return {"success": False, "message": str(e)[:200]}
@router.put("/connections/{service_id}")
async def update_api_key(service_id: str, req: ApiKeyUpdate):
"""API 키를 .env 파일에 저장"""
svc = next((s for s in AI_SERVICES if s["id"] == service_id), None)
if not svc:
raise HTTPException(status_code=404, detail="서비스를 찾을 수 없습니다.")
env_key = svc["env_key"]
api_key = req.api_key.strip()
try:
# .env 파일 읽기
if ENV_PATH.exists():
lines = ENV_PATH.read_text(encoding="utf-8").splitlines()
else:
lines = []
# 기존 키 교체 또는 추가
updated = False
new_lines = []
for line in lines:
if line.startswith(f"{env_key}=") or line.startswith(f"{env_key} ="):
new_lines.append(f"{env_key}={api_key}")
updated = True
else:
new_lines.append(line)
if not updated:
new_lines.append(f"{env_key}={api_key}")
ENV_PATH.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
# 현재 프로세스 환경 변수도 업데이트
os.environ[env_key] = api_key
return {"success": True, "message": f"{env_key} 키 저장 완료"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"키 저장 실패: {e}")

View File

@@ -0,0 +1,173 @@
"""
dashboard/backend/api_content.py
Content 탭 API — 칸반 보드, 승인/거부, 수동 트리거
"""
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = BASE_DIR / "data"
router = APIRouter()
class WriteRequest(BaseModel):
topic: str = ""
def _read_folder_cards(folder: Path, status: str) -> list:
"""폴더에서 JSON 파일을 읽어 칸반 카드 목록 반환"""
cards = []
if not folder.exists():
return cards
for f in sorted(folder.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
data = json.loads(f.read_text(encoding="utf-8"))
cards.append({
"id": f.stem,
"file": str(f),
"title": data.get("title", f.stem),
"corner": data.get("corner", ""),
"source": data.get("source", ""),
"quality_score": data.get("quality_score", data.get("score", 0)),
"created_at": data.get("created_at", data.get("collected_at", "")),
"status": status,
"summary": data.get("summary", data.get("body", "")[:200] if data.get("body") else ""),
})
except Exception:
pass
return cards
@router.get("/content")
async def get_content():
"""칸반 4열 데이터 반환"""
queue = _read_folder_cards(DATA_DIR / "topics", "queue")
queue += _read_folder_cards(DATA_DIR / "collected", "queue")
writing = _read_folder_cards(DATA_DIR / "drafts", "writing")
review = _read_folder_cards(DATA_DIR / "pending_review", "review")
published = _read_folder_cards(DATA_DIR / "published", "published")
return {
"columns": {
"queue": {"label": "글감큐", "cards": queue},
"writing": {"label": "작성중", "cards": writing},
"review": {"label": "검수대기", "cards": review},
"published": {"label": "발행완료", "cards": published[:20]}, # 최근 20개만
}
}
@router.post("/content/{item_id}/approve")
async def approve_content(item_id: str):
"""검수 승인 — pending_review → published로 이동"""
src = DATA_DIR / "pending_review" / f"{item_id}.json"
if not src.exists():
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
try:
data = json.loads(src.read_text(encoding="utf-8"))
data["approved_at"] = datetime.now().isoformat()
data["status"] = "approved"
dst = DATA_DIR / "published" / f"{item_id}.json"
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
src.unlink(missing_ok=True)
return {"success": True, "message": f"{item_id} 승인 완료"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/content/{item_id}/reject")
async def reject_content(item_id: str):
"""검수 거부 — pending_review → discarded로 이동"""
src = DATA_DIR / "pending_review" / f"{item_id}.json"
if not src.exists():
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
try:
data = json.loads(src.read_text(encoding="utf-8"))
data["rejected_at"] = datetime.now().isoformat()
data["status"] = "rejected"
dst = DATA_DIR / "discarded" / f"{item_id}.json"
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
src.unlink(missing_ok=True)
return {"success": True, "message": f"{item_id} 거부 완료"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/manual-write")
async def manual_write(req: WriteRequest):
"""collector_bot + writer_bot 수동 트리거"""
python = sys.executable
bots_dir = BASE_DIR / "bots"
results = []
# collector_bot 실행
collector = bots_dir / "collector_bot.py"
if collector.exists():
try:
result = subprocess.run(
[python, str(collector)],
capture_output=True,
text=True,
timeout=120,
cwd=str(BASE_DIR),
encoding="utf-8",
)
results.append({
"step": "collector",
"success": result.returncode == 0,
"output": result.stdout[-500:] if result.stdout else "",
"error": result.stderr[-300:] if result.stderr else "",
})
except subprocess.TimeoutExpired:
results.append({"step": "collector", "success": False, "error": "타임아웃"})
except Exception as e:
results.append({"step": "collector", "success": False, "error": str(e)})
else:
results.append({"step": "collector", "success": False, "error": "파일 없음"})
# writer_bot 실행
writer = bots_dir / "writer_bot.py"
if writer.exists():
try:
result = subprocess.run(
[python, str(writer)],
capture_output=True,
text=True,
timeout=300,
cwd=str(BASE_DIR),
encoding="utf-8",
)
results.append({
"step": "writer",
"success": result.returncode == 0,
"output": result.stdout[-500:] if result.stdout else "",
"error": result.stderr[-300:] if result.stderr else "",
})
except subprocess.TimeoutExpired:
results.append({"step": "writer", "success": False, "error": "타임아웃"})
except Exception as e:
results.append({"step": "writer", "success": False, "error": str(e)})
else:
results.append({"step": "writer", "success": False, "error": "파일 없음"})
return {"results": results}

View File

@@ -0,0 +1,133 @@
"""
dashboard/backend/api_cost.py
Settings > 비용관리 탭 API — 구독 정보, API 사용량
"""
import json
import re
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter
BASE_DIR = Path(__file__).parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
LOGS_DIR = BASE_DIR / "logs"
router = APIRouter()
SUBSCRIPTION_PLANS = [
{
"id": "claude_pro",
"name": "Claude Pro",
"provider": "Anthropic",
"monthly_cost_usd": 20.0,
"env_key": "ANTHROPIC_API_KEY",
"renewal_day": 1, # 매월 1일 갱신
},
{
"id": "openai_plus",
"name": "OpenAI API",
"provider": "OpenAI",
"monthly_cost_usd": 0.0, # 종량제
"env_key": "OPENAI_API_KEY",
"renewal_day": None,
},
{
"id": "gemini_api",
"name": "Google Gemini API",
"provider": "Google",
"monthly_cost_usd": 0.0, # 무료 티어 + 종량제
"env_key": "GEMINI_API_KEY",
"renewal_day": None,
},
{
"id": "elevenlabs",
"name": "ElevenLabs Starter",
"provider": "ElevenLabs",
"monthly_cost_usd": 5.0,
"env_key": "ELEVENLABS_API_KEY",
"renewal_day": 1,
},
]
def _days_until_renewal(renewal_day):
if renewal_day is None:
return None
today = date.today()
next_renewal = date(today.year, today.month, renewal_day)
if next_renewal <= today:
# 다음 달
if today.month == 12:
next_renewal = date(today.year + 1, 1, renewal_day)
else:
next_renewal = date(today.year, today.month + 1, renewal_day)
return (next_renewal - today).days
def _parse_api_usage() -> list:
"""logs/*.log에서 API 사용량 파싱"""
usage_map: dict = {}
patterns = {
"claude": re.compile(r"claude.*?(\d+)\s*토큰|tokens[:\s]+(\d+)", re.IGNORECASE),
"openai": re.compile(r"openai.*?(\d+)\s*토큰|gpt.*?tokens[:\s]+(\d+)", re.IGNORECASE),
"gemini": re.compile(r"gemini.*?(\d+)\s*토큰", re.IGNORECASE),
}
if not LOGS_DIR.exists():
return []
for log_file in LOGS_DIR.glob("*.log"):
try:
content = log_file.read_text(encoding="utf-8", errors="ignore")
for provider, pattern in patterns.items():
matches = pattern.findall(content)
tokens = sum(int(m[0] or m[1] or 0) for m in matches if any(m))
if tokens:
usage_map[provider] = usage_map.get(provider, 0) + tokens
except Exception:
pass
result = []
for provider, tokens in usage_map.items():
result.append({
"provider": provider,
"tokens": tokens,
"estimated_cost_usd": round(tokens / 1_000_000 * 3.0, 4), # 근사치
})
return result
@router.get("/cost/subscriptions")
async def get_subscriptions():
"""구독 정보 + 만료일 계산"""
import os
from dotenv import load_dotenv
load_dotenv()
subscriptions = []
for plan in SUBSCRIPTION_PLANS:
key_set = bool(os.getenv(plan["env_key"], ""))
days_left = _days_until_renewal(plan.get("renewal_day"))
subscriptions.append({
"id": plan["id"],
"name": plan["name"],
"provider": plan["provider"],
"monthly_cost_usd": plan["monthly_cost_usd"],
"active": key_set,
"renewal_day": plan.get("renewal_day"),
"days_until_renewal": days_left,
"alert": days_left is not None and days_left <= 5,
})
total_monthly = sum(p["monthly_cost_usd"] for p in subscriptions if p["active"])
return {
"subscriptions": subscriptions,
"total_monthly_usd": total_monthly,
}
@router.get("/cost/usage")
async def get_usage():
"""logs에서 API 사용량 파싱"""
return {"usage": _parse_api_usage()}

View File

@@ -0,0 +1,109 @@
"""
dashboard/backend/api_logs.py
Logs 탭 API — 시스템 로그 파싱, 필터/검색
"""
import re
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Query
BASE_DIR = Path(__file__).parent.parent.parent
LOGS_DIR = BASE_DIR / "logs"
router = APIRouter()
LOG_MODULES = {
"": "전체",
"scheduler": "스케줄러",
"collector": "수집",
"writer": "글쓰기",
"converter": "변환",
"publisher": "발행",
"analytics": "분석",
"novel": "소설",
"engine_loader": "엔진",
"error": "에러만",
}
LOG_PATTERN = re.compile(
r"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\.]?\d*\s+"
r"\[?(\w+)\]?\s+(.*)"
)
def _parse_log_line(line: str, module: str) -> dict | None:
m = LOG_PATTERN.match(line.strip())
if not m:
return None
return {
"time": m.group(1),
"level": m.group(2).upper(),
"module": module,
"message": m.group(3)[:300],
}
def _read_logs(
filter_module: str = "",
search: str = "",
limit: int = 200,
) -> list:
logs = []
if not LOGS_DIR.exists():
return logs
# 로그 파일 목록 (최근 수정 순)
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
error_only = filter_module == "error"
for log_file in log_files:
module_name = log_file.stem # e.g. "scheduler", "collector"
# 모듈 필터
if filter_module and not error_only and module_name != filter_module:
continue
try:
lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
for line in reversed(lines):
if not line.strip():
continue
entry = _parse_log_line(line, module_name)
if entry is None:
continue
# 에러만 필터
if error_only and entry["level"] not in ("ERROR", "CRITICAL", "WARNING"):
continue
# 검색 필터
if search and search.lower() not in entry["message"].lower():
continue
logs.append(entry)
if len(logs) >= limit:
break
except Exception:
pass
if len(logs) >= limit:
break
return logs[:limit]
@router.get("/logs")
async def get_logs(
filter: str = Query(default="", description="모듈 필터 (scheduler/collector/writer/converter/publisher/error)"),
search: str = Query(default="", description="메시지 검색"),
limit: int = Query(default=200, ge=1, le=1000),
):
logs = _read_logs(filter_module=filter, search=search, limit=limit)
return {
"logs": logs,
"total": len(logs),
"modules": LOG_MODULES,
}

View File

@@ -0,0 +1,170 @@
"""
dashboard/backend/api_novels.py
Novel 탭 API — 소설 목록, 새 소설 생성, 에피소드 생성
"""
import json
import sys
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
BASE_DIR = Path(__file__).parent.parent.parent
NOVELS_CONFIG_DIR = BASE_DIR / "config" / "novels"
NOVELS_DATA_DIR = BASE_DIR / "data" / "novels"
router = APIRouter()
class NewNovelRequest(BaseModel):
novel_id: str
title: str
title_ko: str
genre: str
setting: str
characters: str
base_story: str
publish_schedule: str = "매주 월/목 09:00"
episode_count_target: int = 50
@router.get("/novels")
async def get_novels():
"""config/novels/*.json 읽어 반환"""
NOVELS_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
novels = []
for path in sorted(NOVELS_CONFIG_DIR.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
# 에피소드 수 계산
ep_dir = NOVELS_DATA_DIR / data.get("novel_id", path.stem) / "episodes"
ep_files = list(ep_dir.glob("ep*.json")) if ep_dir.exists() else []
ep_files = [
f for f in ep_files
if "_summary" not in f.name and "_blog" not in f.name
]
data["episode_files"] = len(ep_files)
# 에피소드 목록 로드
episodes = []
for ef in sorted(ep_files, key=lambda x: x.name)[-10:]: # 최근 10개
try:
ep_data = json.loads(ef.read_text(encoding="utf-8"))
episodes.append({
"episode_num": ep_data.get("episode_num", 0),
"title": ep_data.get("title", ""),
"generated_at": ep_data.get("generated_at", "")[:10],
"word_count": ep_data.get("word_count", 0),
})
except Exception:
pass
data["episodes"] = episodes
# 진행률
target = data.get("episode_count_target", 0)
current = data.get("current_episode", len(ep_files))
data["progress"] = round(current / target * 100) if target else 0
novels.append(data)
except Exception:
pass
return {"novels": novels}
@router.post("/novels")
async def create_novel(req: NewNovelRequest):
"""새 소설 config 생성"""
NOVELS_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_path = NOVELS_CONFIG_DIR / f"{req.novel_id}.json"
if config_path.exists():
raise HTTPException(status_code=409, detail="이미 존재하는 소설 ID입니다.")
novel_config = {
"novel_id": req.novel_id,
"title": req.title,
"title_ko": req.title_ko,
"genre": req.genre,
"setting": req.setting,
"characters": req.characters,
"base_story": req.base_story,
"publish_schedule": req.publish_schedule,
"episode_count_target": req.episode_count_target,
"current_episode": 0,
"status": "active",
"created_at": datetime.now().isoformat(),
"episode_log": [],
}
config_path.write_text(
json.dumps(novel_config, ensure_ascii=False, indent=2),
encoding="utf-8"
)
# 데이터 디렉터리 생성
novel_data_dir = NOVELS_DATA_DIR / req.novel_id
for sub in ["episodes", "shorts", "images"]:
(novel_data_dir / sub).mkdir(parents=True, exist_ok=True)
return {"success": True, "novel_id": req.novel_id, "message": f"소설 '{req.title_ko}' 생성 완료"}
@router.post("/novels/{novel_id}/generate")
async def generate_episode(novel_id: str):
"""다음 에피소드 생성 — NovelManager.run_episode_pipeline() 호출"""
config_path = NOVELS_CONFIG_DIR / f"{novel_id}.json"
if not config_path.exists():
raise HTTPException(status_code=404, detail="소설을 찾을 수 없습니다.")
try:
sys.path.insert(0, str(BASE_DIR / "bots"))
sys.path.insert(0, str(BASE_DIR / "bots" / "novel"))
from bots.novel.novel_manager import NovelManager
manager = NovelManager()
ok = manager.run_episode_pipeline(novel_id, telegram_notify=False)
if ok:
status = manager.get_novel_status(novel_id)
return {
"success": True,
"episode_num": status.get("current_episode", 0),
"message": f"에피소드 생성 완료",
}
else:
raise HTTPException(status_code=500, detail="에피소드 생성 실패 — 로그 확인")
except ImportError as e:
raise HTTPException(status_code=500, detail=f"모듈 로드 실패: {e}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/novels/{novel_id}/episodes")
async def get_episodes(novel_id: str):
"""소설 에피소드 전체 목록"""
ep_dir = NOVELS_DATA_DIR / novel_id / "episodes"
if not ep_dir.exists():
return {"episodes": []}
episodes = []
for ef in sorted(ep_dir.glob("ep*.json"), key=lambda x: x.name):
if "_summary" in ef.name or "_blog" in ef.name:
continue
try:
ep_data = json.loads(ef.read_text(encoding="utf-8"))
episodes.append({
"episode_num": ep_data.get("episode_num", 0),
"title": ep_data.get("title", ""),
"generated_at": ep_data.get("generated_at", "")[:10],
"word_count": ep_data.get("word_count", 0),
"published": ep_data.get("published", False),
})
except Exception:
pass
return {"episodes": episodes}

View File

@@ -0,0 +1,218 @@
"""
dashboard/backend/api_overview.py
Overview 탭 API — KPI, 파이프라인 상태, 활동 로그
"""
import json
import re
from datetime import datetime, date
from pathlib import Path
from typing import List
from fastapi import APIRouter
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = BASE_DIR / "data"
LOGS_DIR = BASE_DIR / "logs"
CONFIG_DIR = BASE_DIR / "config"
router = APIRouter()
CORNER_LABELS = {
"easy_world": "쉬운세상",
"hidden_gem": "숨은보물",
"vibe": "바이브",
"fact_check": "팩트체크",
"deep_dive": "딥다이브",
"novel": "연재소설",
}
def _count_published_files() -> dict:
"""published 폴더에서 오늘/이번주/총 발행 수 카운트"""
published_dir = DATA_DIR / "published"
if not published_dir.exists():
return {"today": 0, "this_week": 0, "total": 0, "corners": {}}
today = date.today()
week_start = today.toordinal() - today.weekday()
today_count = 0
week_count = 0
total_count = 0
corner_counts: dict = {}
for f in published_dir.glob("*.json"):
total_count += 1
try:
data = json.loads(f.read_text(encoding="utf-8"))
published_at_str = data.get("published_at", "")
corner = data.get("corner", "기타")
corner_counts[corner] = corner_counts.get(corner, 0) + 1
if published_at_str:
try:
pub_date = datetime.fromisoformat(
published_at_str[:19]
).date()
if pub_date == today:
today_count += 1
if pub_date.toordinal() >= week_start:
week_count += 1
except Exception:
pass
except Exception:
pass
return {
"today": today_count,
"this_week": week_count,
"total": total_count,
"corners": corner_counts,
}
def _get_revenue() -> dict:
"""analytics 폴더에서 수익 데이터 읽기"""
analytics_dir = DATA_DIR / "analytics"
if not analytics_dir.exists():
return {"amount": 0.0, "currency": "USD", "status": "대기중"}
latest = None
for f in sorted(analytics_dir.glob("*.json"), reverse=True):
try:
data = json.loads(f.read_text(encoding="utf-8"))
if "revenue" in data:
latest = data["revenue"]
break
except Exception:
pass
if latest is None:
return {"amount": 0.0, "currency": "USD", "status": "대기중"}
return latest
def _parse_pipeline_status() -> List[dict]:
"""scheduler.log에서 파이프라인 단계별 상태 파싱"""
steps = [
{"id": "collector", "name": "수집", "status": "waiting", "done_at": ""},
{"id": "writer", "name": "글쓰기", "status": "waiting", "done_at": ""},
{"id": "converter", "name": "변환", "status": "waiting", "done_at": ""},
{"id": "publisher", "name": "발행", "status": "waiting", "done_at": ""},
{"id": "uploader", "name": "유튜브 업로드", "status": "waiting", "done_at": ""},
{"id": "analytics", "name": "분석", "status": "waiting", "done_at": ""},
]
log_file = LOGS_DIR / "scheduler.log"
if not log_file.exists():
return steps
try:
lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
today_str = date.today().strftime("%Y-%m-%d")
for line in lines:
if today_str not in line:
continue
low = line.lower()
for step in steps:
sid = step["id"]
if sid in low:
# 타임스탬프 파싱
m = re.match(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
ts = m.group(1)[11:16] if m else ""
if "완료" in line or "done" in low or "success" in low or "finish" in low:
step["status"] = "done"
step["done_at"] = ts
elif "시작" in line or "start" in low or "running" in low:
step["status"] = "running"
step["done_at"] = ts
elif "오류" in line or "error" in low or "fail" in low:
step["status"] = "error"
step["done_at"] = ts
except Exception:
pass
return steps
def _get_activity_logs() -> List[dict]:
"""logs/*.log에서 최근 20개 활동 로그 파싱"""
logs = []
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
for log_file in log_files[:5]: # 최근 5개 파일만
try:
lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
for line in reversed(lines):
if not line.strip():
continue
m = re.match(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),?\d*\s+\[(\w+)\]\s+(.*)", line)
if m:
logs.append({
"time": m.group(1)[11:16],
"date": m.group(1)[:10],
"level": m.group(2),
"module": log_file.stem,
"message": m.group(3)[:120],
})
if len(logs) >= 20:
break
except Exception:
pass
if len(logs) >= 20:
break
return logs[:20]
def _get_corner_ratio(corner_counts: dict) -> List[dict]:
"""코너별 발행 비율 계산"""
total = sum(corner_counts.values()) or 1
result = []
for key, label in CORNER_LABELS.items():
count = corner_counts.get(key, corner_counts.get(label, 0))
result.append({
"name": label,
"count": count,
"ratio": round(count / total * 100),
})
# 정의되지 않은 코너 추가
known = set(CORNER_LABELS.keys()) | set(CORNER_LABELS.values())
for k, v in corner_counts.items():
if k not in known:
result.append({
"name": k,
"count": v,
"ratio": round(v / total * 100),
})
result.sort(key=lambda x: x["count"], reverse=True)
return result
@router.get("/overview")
async def get_overview():
counts = _count_published_files()
revenue = _get_revenue()
return {
"kpi": {
"today": counts["today"],
"this_week": counts["this_week"],
"total": counts["total"],
"revenue": revenue,
},
"corner_ratio": _get_corner_ratio(counts["corners"]),
}
@router.get("/pipeline")
async def get_pipeline():
return {"steps": _parse_pipeline_status()}
@router.get("/activity")
async def get_activity():
return {"logs": _get_activity_logs()}

View File

@@ -0,0 +1,43 @@
"""
dashboard/backend/api_settings.py
Settings 탭 API — engine.json 읽기/쓰기
"""
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
BASE_DIR = Path(__file__).parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
router = APIRouter()
class SettingsUpdate(BaseModel):
data: dict
@router.get("/settings")
async def get_settings():
"""config/engine.json 반환"""
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception as e:
raise HTTPException(status_code=500, detail=f"설정 파일 읽기 실패: {e}")
@router.put("/settings")
async def update_settings(req: SettingsUpdate):
"""config/engine.json 저장"""
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps(req.data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
return {"success": True, "message": "설정 저장 완료"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"설정 저장 실패: {e}")

View File

@@ -0,0 +1,113 @@
"""
dashboard/backend/api_tools.py
Settings > 생성도구 선택 탭 API
"""
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
BASE_DIR = Path(__file__).parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
router = APIRouter()
TOOL_CATEGORIES = {
"writing": {
"label": "글쓰기",
"options": ["claude", "gemini", "openclaw"],
"option_labels": {
"claude": "Claude (Anthropic)",
"gemini": "Google Gemini",
"openclaw": "OpenClaw AI",
},
},
"image_generation": {
"label": "이미지 생성",
"options": ["dalle", "external"],
"option_labels": {
"dalle": "DALL-E 3 (OpenAI)",
"external": "수동 제공",
},
},
"tts": {
"label": "TTS (음성합성)",
"options": ["google_cloud", "openai", "elevenlabs", "gtts"],
"option_labels": {
"google_cloud": "Google Cloud TTS",
"openai": "OpenAI TTS (tts-1-hd)",
"elevenlabs": "ElevenLabs",
"gtts": "gTTS (무료)",
},
},
"video_generation": {
"label": "영상 생성",
"options": ["ffmpeg_slides", "seedance", "runway", "sora", "veo"],
"option_labels": {
"ffmpeg_slides": "FFmpeg 슬라이드 (로컬)",
"seedance": "Seedance 2.0",
"runway": "Runway Gen-3",
"sora": "OpenAI Sora",
"veo": "Google Veo",
},
},
}
class ToolUpdate(BaseModel):
tools: dict # {"writing": "claude", "tts": "gtts", ...}
def _load_config() -> dict:
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
@router.get("/tools")
async def get_tools():
"""현재 선택된 도구 + 선택 가능 목록 반환"""
config = _load_config()
result = {}
for category, meta in TOOL_CATEGORIES.items():
current = config.get(category, {}).get("provider", meta["options"][0])
result[category] = {
"label": meta["label"],
"current": current,
"options": [
{
"value": opt,
"label": meta["option_labels"].get(opt, opt),
}
for opt in meta["options"]
],
}
return {"tools": result}
@router.put("/tools")
async def update_tools(req: ToolUpdate):
"""engine.json 도구 섹션 업데이트"""
config = _load_config()
for category, provider in req.tools.items():
if category in TOOL_CATEGORIES:
if category not in config:
config[category] = {}
config[category]["provider"] = provider
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8"
)
return {"success": True, "message": "도구 설정 저장 완료"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"저장 실패: {e}")

View File

@@ -0,0 +1,78 @@
"""
dashboard/backend/server.py
미디어 엔진 컨트롤 패널 — FastAPI 메인 서버
실행: uvicorn dashboard.backend.server:app --port 8080
또는: python -m uvicorn dashboard.backend.server:app --port 8080 --reload
"""
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from dashboard.backend import (
api_overview,
api_content,
api_analytics,
api_novels,
api_settings,
api_connections,
api_tools,
api_cost,
api_logs,
)
app = FastAPI(title="The 4th Path — Control Panel", version="1.0.0")
# ── CORS ──────────────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:8080",
"http://127.0.0.1:5173",
"http://127.0.0.1:8080",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── API 라우터 등록 ────────────────────────────────────────────────────────────
app.include_router(api_overview.router, prefix="/api")
app.include_router(api_content.router, prefix="/api")
app.include_router(api_analytics.router, prefix="/api")
app.include_router(api_novels.router, prefix="/api")
app.include_router(api_settings.router, prefix="/api")
app.include_router(api_connections.router, prefix="/api")
app.include_router(api_tools.router, prefix="/api")
app.include_router(api_cost.router, prefix="/api")
app.include_router(api_logs.router, prefix="/api")
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "The 4th Path Control Panel"}
# ── 정적 파일 서빙 (프론트엔드 빌드 결과) — API 라우터보다 나중에 등록 ──────────
FRONTEND_DIST = Path(__file__).parent.parent / "frontend" / "dist"
if FRONTEND_DIST.exists():
assets_dir = FRONTEND_DIST / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
@app.get("/", include_in_schema=False)
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str = ""):
# API 경로는 위 라우터가 처리 — 여기는 SPA 라우팅용
if full_path.startswith("api/"):
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Not Found"}, status_code=404)
index = FRONTEND_DIST / "index.html"
if index.exists():
return FileResponse(str(index))
return {"status": "frontend not built — run: npm run build"}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The 4th Path · Control Panel</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "blog-writer-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.7",
"lucide-react": "^0.400.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.1",
"tailwindcss": "^3.4.10",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,71 @@
import React, { useState } from 'react'
import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText } from 'lucide-react'
import Overview from './pages/Overview.jsx'
import Content from './pages/Content.jsx'
import Analytics from './pages/Analytics.jsx'
import Novel from './pages/Novel.jsx'
import SettingsPage from './pages/Settings.jsx'
import Logs from './pages/Logs.jsx'
const TABS = [
{ id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview },
{ id: 'content', label: '콘텐츠', icon: FileText, component: Content },
{ id: 'analytics', label: '분석', icon: BarChart2, component: Analytics },
{ id: 'novel', label: '소설', icon: BookOpen, component: Novel },
{ id: 'settings', label: '설정', icon: Settings, component: SettingsPage },
{ id: 'logs', label: '로그', icon: ScrollText, component: Logs },
]
export default function App() {
const [activeTab, setActiveTab] = useState('overview')
const [systemStatus, setSystemStatus] = useState('ok') // ok | warn | error
const ActiveComponent = TABS.find(t => t.id === activeTab)?.component || Overview
return (
<div className="flex flex-col h-screen bg-bg text-text overflow-hidden">
{/* 헤더 */}
<header className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-border bg-card flex-shrink-0">
<div className="flex items-center gap-3">
<span className="text-accent font-bold text-base md:text-lg tracking-tight">
The 4th Path
</span>
<span className="hidden md:inline text-subtext text-xs">· Control Panel</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${systemStatus === 'ok' ? 'bg-success' : systemStatus === 'warn' ? 'bg-warning' : 'bg-error'}`}></span>
<span className="text-xs text-subtext hidden sm:inline">
{systemStatus === 'ok' ? 'System OK' : systemStatus === 'warn' ? '경고' : '오류'}
</span>
</div>
</header>
{/* 탭 네비게이션 */}
<nav className="flex border-b border-border bg-card flex-shrink-0 overflow-x-auto">
{TABS.map(tab => {
const Icon = tab.icon
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-3 md:px-5 py-3 text-xs md:text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
isActive
? 'border-accent text-accent'
: 'border-transparent text-subtext hover:text-text'
}`}
>
<Icon size={15} />
<span className="hidden sm:inline">{tab.label}</span>
</button>
)
})}
</nav>
{/* 메인 컨텐츠 */}
<main className="flex-1 overflow-y-auto">
<ActiveComponent />
</main>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles/theme.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Cell,
} from 'recharts'
import { Loader2, RefreshCw, Users, Eye, Clock, MousePointerClick } from 'lucide-react'
const PERIOD_DAYS = { '이번주': 7, '이번달': 30, '전체': 365 }
const PLATFORM_COLORS = { blogger: '#c8a84e', youtube: '#bf3a3a', instagram: '#4a5abf', x: '#888880' }
function KpiCard({ label, value, icon: Icon, color }) {
return (
<div className="card p-4 flex items-center gap-3">
<div className={`p-2 rounded-lg bg-border ${color}`}>
<Icon size={18} />
</div>
<div>
<div className="text-xs text-subtext">{label}</div>
<div className="text-xl font-bold text-text">{value}</div>
</div>
</div>
)
}
function formatSec(sec) {
if (!sec) return '0분'
const m = Math.floor(sec / 60)
const s = sec % 60
return m > 0 ? `${m}${s}` : `${s}`
}
export default function Analytics() {
const [period, setPeriod] = useState('이번주')
const [analytics, setAnalytics] = useState(null)
const [chart, setChart] = useState([])
const [loading, setLoading] = useState(true)
const fetchData = async () => {
setLoading(true)
try {
const days = PERIOD_DAYS[period]
const [aRes, cRes] = await Promise.all([
fetch('/api/analytics'),
fetch(`/api/analytics/chart?days=${days}`),
])
const a = await aRes.json()
const c = await cRes.json()
setAnalytics(a)
setChart(c.chart || [])
} catch (e) {
console.error('Analytics 로드 실패:', e)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [period])
if (loading && !analytics) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-accent" size={32} />
</div>
)
}
const kpi = analytics?.kpi || {}
const corners = analytics?.corners || []
const topPosts = analytics?.top_posts || []
const platforms = analytics?.platforms || []
return (
<div className="p-4 md:p-6 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold">성과 분석</h1>
<div className="flex items-center gap-2">
{/* 기간 선택 */}
<div className="flex rounded border border-border overflow-hidden">
{Object.keys(PERIOD_DAYS).map(p => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 text-xs transition-colors ${
period === p
? 'bg-accent text-bg font-semibold'
: 'text-subtext hover:text-text'
}`}
>
{p}
</button>
))}
</div>
<button
onClick={fetchData}
className="text-xs text-subtext hover:text-accent"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{/* KPI 카드 4개 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard label="방문자" value={kpi.visitors?.toLocaleString() || '0'} icon={Users} color="text-accent" />
<KpiCard label="페이지뷰" value={kpi.pageviews?.toLocaleString() || '0'} icon={Eye} color="text-info" />
<KpiCard label="평균 체류시간" value={formatSec(kpi.avg_duration_sec)} icon={Clock} color="text-success" />
<KpiCard label="CTR" value={`${kpi.ctr || 0}%`} icon={MousePointerClick} color="text-warning" />
</div>
{/* 방문자 라인차트 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-4">방문자 추이 ({period})</h2>
{chart.length === 0 ? (
<div className="flex items-center justify-center h-40 text-subtext text-sm">
데이터 없음
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chart} margin={{ left: 0, right: 8, top: 4, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222228" />
<XAxis
dataKey="date"
tick={{ fill: '#888880', fontSize: 11 }}
tickFormatter={d => d.slice(5)}
/>
<YAxis tick={{ fill: '#888880', fontSize: 11 }} width={40} />
<Tooltip
contentStyle={{ background: '#111116', border: '1px solid #222228', borderRadius: 6 }}
labelStyle={{ color: '#e0e0d8' }}
/>
<Line
type="monotone"
dataKey="visitors"
name="방문자"
stroke="#c8a84e"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="pageviews"
name="페이지뷰"
stroke="#3a7d5c"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 코너별 성과 테이블 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-3">코너별 성과</h2>
{corners.length === 0 ? (
<p className="text-subtext text-sm">데이터 없음</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-subtext border-b border-border">
<th className="text-left py-2">코너</th>
<th className="text-right py-2">방문자</th>
<th className="text-right py-2">페이지뷰</th>
<th className="text-right py-2"> </th>
</tr>
</thead>
<tbody>
{corners.map((c, idx) => (
<tr key={idx} className="border-b border-border/50 hover:bg-card-hover">
<td className="py-2">{c.corner}</td>
<td className="py-2 text-right text-accent">{c.visitors.toLocaleString()}</td>
<td className="py-2 text-right">{c.pageviews.toLocaleString()}</td>
<td className="py-2 text-right text-subtext">{c.posts}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* 인기글 TOP 5 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-3">인기글 TOP 5</h2>
{topPosts.length === 0 ? (
<p className="text-subtext text-sm">데이터 없음</p>
) : (
<div className="space-y-2">
{topPosts.map((post, idx) => (
<div key={idx} className="flex items-start gap-3 py-2 border-b border-border/50 last:border-0">
<span className="text-accent font-bold text-sm w-5 flex-shrink-0">{idx + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-text truncate">{post.title}</p>
<div className="flex gap-3 mt-0.5">
<span className="text-xs text-subtext">{post.corner}</span>
<span className="text-xs text-accent">{post.visitors?.toLocaleString()} 방문</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 플랫폼별 성과 */}
{platforms.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-4">플랫폼별 성과</h2>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={platforms} margin={{ left: 0, right: 8, top: 4, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222228" />
<XAxis dataKey="platform" tick={{ fill: '#888880', fontSize: 12 }} />
<YAxis tick={{ fill: '#888880', fontSize: 11 }} width={40} />
<Tooltip
contentStyle={{ background: '#111116', border: '1px solid #222228', borderRadius: 6 }}
labelStyle={{ color: '#e0e0d8' }}
/>
<Bar dataKey="visitors" name="방문자" radius={[4, 4, 0, 0]}>
{platforms.map((p, idx) => (
<Cell key={idx} fill={PLATFORM_COLORS[p.platform] || '#4a5abf'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react'
import { Loader2, CheckCircle2, XCircle, RefreshCw, X, Star, ExternalLink } from 'lucide-react'
const STATUS_COLORS = {
queue: 'bg-info/10 text-info border-info/30',
writing: 'bg-warning/10 text-warning border-warning/30',
review: 'bg-accent/10 text-accent border-accent/30',
published: 'bg-success/10 text-success border-success/30',
}
const STATUS_BADGE = {
queue: 'badge-waiting',
writing: 'badge-running',
review: 'badge-running',
published: 'badge-done',
}
function QualityBar({ score }) {
if (!score) return null
const color = score >= 80 ? '#3a7d5c' : score >= 60 ? '#c8a84e' : '#bf3a3a'
return (
<div className="flex items-center gap-1.5 mt-1">
<div className="flex-1 h-1 bg-border rounded-full overflow-hidden">
<div style={{ width: `${score}%`, background: color }} className="h-full rounded-full" />
</div>
<span className="text-xs font-mono" style={{ color }}>{score}</span>
</div>
)
}
function CardModal({ card, onClose, onApprove, onReject }) {
if (!card) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="card w-full max-w-lg mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-semibold text-text mb-1">{card.title}</h3>
<div className="flex gap-2">
{card.corner && (
<span className="tag bg-accent/10 text-accent border border-accent/20">{card.corner}</span>
)}
<span className={`tag ${STATUS_BADGE[card.status]}`}>{card.status}</span>
</div>
</div>
<button onClick={onClose} className="text-subtext hover:text-text">
<X size={18} />
</button>
</div>
{card.quality_score > 0 && (
<div className="mb-3">
<div className="flex items-center gap-1 text-xs text-subtext mb-1">
<Star size={11} />
품질 점수
</div>
<QualityBar score={card.quality_score} />
</div>
)}
{card.source && (
<div className="mb-3">
<p className="text-xs text-subtext mb-1">출처</p>
<p className="text-xs text-info break-all">{card.source}</p>
</div>
)}
{card.summary && (
<div className="mb-4">
<p className="text-xs text-subtext mb-1">내용 요약</p>
<p className="text-sm text-text leading-relaxed line-clamp-4">{card.summary}</p>
</div>
)}
{card.created_at && (
<p className="text-xs text-subtext mb-4">생성일: {card.created_at?.slice(0, 16)}</p>
)}
{card.status === 'review' && (
<div className="flex gap-2">
<button
onClick={() => onApprove(card.id)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-success text-white text-sm font-medium rounded hover:opacity-80 transition-opacity"
>
<CheckCircle2 size={14} />
승인
</button>
<button
onClick={() => onReject(card.id)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-error text-white text-sm font-medium rounded hover:opacity-80 transition-opacity"
>
<XCircle size={14} />
거부
</button>
</div>
)}
</div>
</div>
)
}
function KanbanCard({ card, onClick }) {
return (
<div
className="card p-3 cursor-pointer hover:border-accent/50 transition-colors mb-2"
onClick={() => onClick(card)}
>
<p className="text-sm font-medium text-text line-clamp-2 mb-1">{card.title}</p>
{card.corner && (
<span className="tag bg-accent/10 text-accent text-xs">{card.corner}</span>
)}
<QualityBar score={card.quality_score} />
{card.status === 'review' && (
<div className="flex gap-1 mt-2" onClick={e => e.stopPropagation()}>
<button
onClick={async () => {
await fetch(`/api/content/${card.id}/approve`, { method: 'POST' })
window.location.reload()
}}
className="flex-1 text-xs py-1 bg-success/20 text-success rounded hover:bg-success/30 transition-colors"
>
승인
</button>
<button
onClick={async () => {
await fetch(`/api/content/${card.id}/reject`, { method: 'POST' })
window.location.reload()
}}
className="flex-1 text-xs py-1 bg-error/20 text-error rounded hover:bg-error/30 transition-colors"
>
거부
</button>
</div>
)}
</div>
)
}
export default function Content() {
const [columns, setColumns] = useState({})
const [loading, setLoading] = useState(true)
const [selectedCard, setSelectedCard] = useState(null)
const [actionLoading, setActionLoading] = useState(false)
const fetchContent = async () => {
setLoading(true)
try {
const res = await fetch('/api/content')
const data = await res.json()
setColumns(data.columns || {})
} catch (e) {
console.error('Content 로드 실패:', e)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchContent() }, [])
const handleApprove = async (id) => {
setActionLoading(true)
try {
await fetch(`/api/content/${id}/approve`, { method: 'POST' })
setSelectedCard(null)
await fetchContent()
} finally {
setActionLoading(false)
}
}
const handleReject = async (id) => {
setActionLoading(true)
try {
await fetch(`/api/content/${id}/reject`, { method: 'POST' })
setSelectedCard(null)
await fetchContent()
} finally {
setActionLoading(false)
}
}
const handleManualWrite = async () => {
if (!confirm('수집 + 글쓰기 봇을 수동으로 실행할까요? 수 분이 걸릴 수 있습니다.')) return
try {
const res = await fetch('/api/manual-write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = await res.json()
const msg = data.results?.map(r => `${r.step}: ${r.success ? '성공' : r.error || '실패'}`).join('\n')
alert(msg || '실행 완료')
await fetchContent()
} catch (e) {
alert('실행 실패: ' + e.message)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-accent" size={32} />
</div>
)
}
const colOrder = ['queue', 'writing', 'review', 'published']
return (
<div className="p-4 md:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-lg font-bold">콘텐츠 관리</h1>
<div className="flex gap-2">
<button
onClick={fetchContent}
className="flex items-center gap-1.5 text-xs text-subtext hover:text-accent border border-border px-3 py-1.5 rounded transition-colors"
>
<RefreshCw size={12} />
새로고침
</button>
<button
onClick={handleManualWrite}
className="flex items-center gap-1.5 text-xs bg-accent text-bg font-semibold px-3 py-1.5 rounded hover:opacity-80 transition-opacity"
>
수동 글쓰기 실행
</button>
</div>
</div>
{/* 칸반 보드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{colOrder.map(colId => {
const col = columns[colId]
if (!col) return null
const cards = col.cards || []
return (
<div key={colId} className="bg-card/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-accent">{col.label}</h3>
<span className="text-xs bg-border text-subtext px-2 py-0.5 rounded-full">
{cards.length}
</span>
</div>
<div className="kanban-col overflow-y-auto max-h-[60vh]">
{cards.length === 0 ? (
<p className="text-xs text-subtext text-center py-8">비어있음</p>
) : (
cards.map(card => (
<KanbanCard key={card.id} card={card} onClick={setSelectedCard} />
))
)}
</div>
</div>
)
})}
</div>
{/* 카드 상세 모달 */}
{selectedCard && (
<CardModal
card={selectedCard}
onClose={() => setSelectedCard(null)}
onApprove={handleApprove}
onReject={handleReject}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Loader2, RefreshCw, Search, Filter } from 'lucide-react'
const LEVEL_STYLES = {
ERROR: 'text-error',
CRITICAL: 'text-error font-semibold',
WARNING: 'text-warning',
INFO: 'text-info',
DEBUG: 'text-subtext',
}
const FILTERS = [
{ value: '', label: '전체' },
{ value: 'scheduler', label: '스케줄러' },
{ value: 'collector', label: '수집' },
{ value: 'writer', label: '글쓰기' },
{ value: 'converter', label: '변환' },
{ value: 'publisher', label: '발행' },
{ value: 'novel', label: '소설' },
{ value: 'analytics', label: '분석' },
{ value: 'error', label: '에러만' },
]
export default function Logs() {
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(true)
const [filterModule, setFilterModule] = useState('')
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [total, setTotal] = useState(0)
const [autoRefresh, setAutoRefresh] = useState(false)
const fetchLogs = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterModule) params.set('filter', filterModule)
if (search) params.set('search', search)
params.set('limit', '200')
const res = await fetch(`/api/logs?${params}`)
const data = await res.json()
setLogs(data.logs || [])
setTotal(data.total || 0)
} catch (e) {
console.error('Logs 로드 실패:', e)
} finally {
setLoading(false)
}
}, [filterModule, search])
useEffect(() => {
fetchLogs()
}, [fetchLogs])
useEffect(() => {
if (!autoRefresh) return
const timer = setInterval(fetchLogs, 5000)
return () => clearInterval(timer)
}, [autoRefresh, fetchLogs])
const handleSearch = (e) => {
e.preventDefault()
setSearch(searchInput)
}
const levelCount = (level) => logs.filter(l => l.level === level).length
return (
<div className="p-4 md:p-6 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold">시스템 로그</h1>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-subtext cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-accent"
/>
자동 새로고침 (5)
</label>
<button
onClick={fetchLogs}
className="text-xs text-subtext hover:text-accent flex items-center gap-1 border border-border px-2 py-1 rounded"
>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
새로고침
</button>
</div>
</div>
{/* 필터 + 검색 */}
<div className="flex flex-col sm:flex-row gap-3">
{/* 모듈 필터 드롭다운 */}
<div className="flex items-center gap-2">
<Filter size={14} className="text-subtext flex-shrink-0" />
<div className="flex flex-wrap gap-1">
{FILTERS.map(f => (
<button
key={f.value}
onClick={() => setFilterModule(f.value)}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
filterModule === f.value
? 'bg-accent text-bg font-semibold'
: 'border border-border text-subtext hover:text-text'
}`}
>
{f.label}
</button>
))}
</div>
</div>
{/* 검색 */}
<form onSubmit={handleSearch} className="flex gap-2 ml-auto">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-subtext" />
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="메시지 검색..."
className="bg-bg border border-border rounded pl-7 pr-3 py-1.5 text-xs text-text placeholder-subtext focus:outline-none focus:border-accent w-48"
/>
</div>
<button type="submit" className="text-xs bg-accent text-bg px-3 py-1.5 rounded font-semibold hover:opacity-80">
검색
</button>
{search && (
<button
type="button"
onClick={() => { setSearch(''); setSearchInput('') }}
className="text-xs text-subtext hover:text-text"
>
초기화
</button>
)}
</form>
</div>
{/* 통계 바 */}
<div className="flex gap-4 text-xs">
<span className="text-subtext"> {total}</span>
{levelCount('ERROR') > 0 && <span className="text-error">오류 {levelCount('ERROR')}</span>}
{levelCount('WARNING') > 0 && <span className="text-warning">경고 {levelCount('WARNING')}</span>}
{levelCount('INFO') > 0 && <span className="text-info">정보 {levelCount('INFO')}</span>}
</div>
{/* 로그 리스트 */}
<div className="card overflow-hidden">
{loading && logs.length === 0 ? (
<div className="flex justify-center py-12">
<Loader2 className="animate-spin text-accent" size={24} />
</div>
) : logs.length === 0 ? (
<div className="text-center py-12 text-subtext text-sm">
로그가 없습니다.
</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-full">
{/* 테이블 헤더 */}
<div className="flex gap-3 px-3 py-2 border-b border-border text-xs text-subtext bg-bg/50 sticky top-0">
<span className="w-36 flex-shrink-0">시각</span>
<span className="w-16 flex-shrink-0">레벨</span>
<span className="w-24 flex-shrink-0">모듈</span>
<span className="flex-1">메시지</span>
</div>
{/* 로그 행 */}
<div className="max-h-[calc(100vh-320px)] overflow-y-auto">
{logs.map((log, idx) => (
<div
key={idx}
className={`flex gap-3 px-3 py-1.5 border-b border-border/30 hover:bg-card-hover text-xs font-mono ${
idx % 2 === 0 ? '' : 'bg-black/10'
}`}
>
<span className="w-36 flex-shrink-0 text-subtext whitespace-nowrap">
{log.time}
</span>
<span className={`w-16 flex-shrink-0 ${LEVEL_STYLES[log.level] || 'text-subtext'}`}>
{log.level}
</span>
<span className="w-24 flex-shrink-0 text-subtext truncate">
[{log.module}]
</span>
<span className="flex-1 text-text break-all">
{log.message}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react'
import { Loader2, RefreshCw, Plus, X, Play, BookOpen, ChevronDown, ChevronUp } from 'lucide-react'
function ProgressBar({ value, max }) {
const pct = max > 0 ? Math.min(100, Math.round(value / max * 100)) : 0
const color = pct >= 80 ? '#3a7d5c' : pct >= 40 ? '#c8a84e' : '#4a5abf'
return (
<div>
<div className="flex justify-between text-xs text-subtext mb-1">
<span>{value} / {max} </span>
<span style={{ color }}>{pct}%</span>
</div>
<div className="w-full h-1.5 bg-border rounded-full overflow-hidden">
<div style={{ width: `${pct}%`, background: color }} className="h-full rounded-full transition-all" />
</div>
</div>
)
}
function NewNovelModal({ onClose, onCreated }) {
const [form, setForm] = useState({
novel_id: '',
title: '',
title_ko: '',
genre: '',
setting: '',
characters: '',
base_story: '',
publish_schedule: '매주 월/목 09:00',
episode_count_target: 50,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/novels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, episode_count_target: Number(form.episode_count_target) }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '생성 실패')
}
const data = await res.json()
onCreated(data)
onClose()
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const field = (name, label, placeholder = '', type = 'text', rows = 0) => (
<div>
<label className="block text-xs text-subtext mb-1">{label}</label>
{rows > 0 ? (
<textarea
rows={rows}
value={form[name]}
onChange={e => setForm(f => ({ ...f, [name]: e.target.value }))}
placeholder={placeholder}
className="w-full bg-bg border border-border rounded px-3 py-2 text-sm text-text placeholder-subtext focus:outline-none focus:border-accent resize-none"
required
/>
) : (
<input
type={type}
value={form[name]}
onChange={e => setForm(f => ({ ...f, [name]: e.target.value }))}
placeholder={placeholder}
className="w-full bg-bg border border-border rounded px-3 py-2 text-sm text-text placeholder-subtext focus:outline-none focus:border-accent"
required
/>
)}
</div>
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 overflow-y-auto py-6" onClick={onClose}>
<div className="card w-full max-w-lg mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-accent flex items-center gap-2">
<Plus size={16} />
소설 만들기
</h3>
<button onClick={onClose} className="text-subtext hover:text-text"><X size={18} /></button>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
{field('novel_id', '소설 ID (영문)', 'shadow-protocol')}
{field('genre', '장르', '판타지 / SF / 로맨스')}
</div>
{field('title', '영문 제목', 'Shadow Protocol')}
{field('title_ko', '한국어 제목', '그림자 프로토콜')}
{field('setting', '세계관 설정', '2050년 서울, AI와 인간이 공존하는 사회...', 'text', 3)}
{field('characters', '주요 등장인물', '주인공: 김하준(29세, AI 보안 전문가)...', 'text', 3)}
{field('base_story', '기본 스토리', '주인공이 우연히 금지된 AI를 발견하면서...', 'text', 4)}
<div className="grid grid-cols-2 gap-3">
{field('publish_schedule', '발행 일정', '매주 월/목 09:00')}
{field('episode_count_target', '목표 에피소드', '50', 'number')}
</div>
{error && <p className="text-error text-xs">{error}</p>}
<div className="flex gap-2 pt-2">
<button type="button" onClick={onClose} className="flex-1 py-2 border border-border text-sm rounded hover:border-accent/50 transition-colors">
취소
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 bg-accent text-bg text-sm font-semibold rounded hover:opacity-80 transition-opacity disabled:opacity-50"
>
{loading ? <Loader2 size={14} className="animate-spin inline" /> : '소설 생성'}
</button>
</div>
</form>
</div>
</div>
)
}
function NovelCard({ novel, onGenerate }) {
const [expanded, setExpanded] = useState(false)
const [generating, setGenerating] = useState(false)
const handleGenerate = async () => {
if (!confirm(`"${novel.title_ko}" 다음 에피소드를 생성할까요? 수 분이 걸릴 수 있습니다.`)) return
setGenerating(true)
try {
const res = await fetch(`/api/novels/${novel.novel_id}/generate`, { method: 'POST' })
const data = await res.json()
if (data.success) {
alert(`에피소드 ${data.episode_num}화 생성 완료!`)
onGenerate()
} else {
alert('생성 실패: ' + (data.detail || '알 수 없는 오류'))
}
} catch (e) {
alert('생성 실패: ' + e.message)
} finally {
setGenerating(false)
}
}
return (
<div className="card p-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-text">{novel.title_ko}</h3>
<div className="flex gap-2 mt-1">
<span className="tag bg-info/10 text-info">{novel.genre}</span>
<span className={`tag ${novel.status === 'active' ? 'badge-done' : 'badge-waiting'}`}>
{novel.status === 'active' ? '연재중' : '중단'}
</span>
</div>
</div>
<button
onClick={handleGenerate}
disabled={generating}
className="flex items-center gap-1.5 text-xs bg-accent text-bg px-3 py-1.5 rounded font-semibold hover:opacity-80 transition-opacity disabled:opacity-50"
>
{generating ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
다음 생성
</button>
</div>
<ProgressBar value={novel.current_episode || 0} max={novel.episode_count_target || 50} />
{novel.publish_schedule && (
<p className="text-xs text-subtext mt-2">연재 일정: {novel.publish_schedule}</p>
)}
{/* 에피소드 테이블 토글 */}
{(novel.episodes?.length > 0) && (
<div className="mt-3">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-subtext hover:text-accent transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
에피소드 목록 ({novel.episodes.length})
</button>
{expanded && (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-subtext border-b border-border">
<th className="text-left py-1.5">화수</th>
<th className="text-left py-1.5">제목</th>
<th className="text-right py-1.5">생성일</th>
<th className="text-right py-1.5">분량</th>
</tr>
</thead>
<tbody>
{novel.episodes.map((ep, idx) => (
<tr key={idx} className="border-b border-border/50 hover:bg-card-hover">
<td className="py-1.5 text-accent font-mono">{ep.episode_num}</td>
<td className="py-1.5 max-w-[200px] truncate">{ep.title}</td>
<td className="py-1.5 text-right text-subtext">{ep.generated_at}</td>
<td className="py-1.5 text-right text-subtext">{ep.word_count?.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}
export default function Novel() {
const [novels, setNovels] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const fetchNovels = async () => {
setLoading(true)
try {
const res = await fetch('/api/novels')
const data = await res.json()
setNovels(data.novels || [])
} catch (e) {
console.error('Novel 로드 실패:', e)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchNovels() }, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-accent" size={32} />
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold flex items-center gap-2">
<BookOpen size={20} className="text-accent" />
소설 연재 관리
</h1>
<div className="flex gap-2">
<button
onClick={fetchNovels}
className="text-xs text-subtext hover:text-accent border border-border px-3 py-1.5 rounded flex items-center gap-1.5 transition-colors"
>
<RefreshCw size={12} />
새로고침
</button>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-1.5 text-xs bg-accent text-bg font-semibold px-3 py-1.5 rounded hover:opacity-80 transition-opacity"
>
<Plus size={13} />
소설 만들기
</button>
</div>
</div>
{novels.length === 0 ? (
<div className="card p-8 text-center">
<BookOpen size={40} className="text-subtext mx-auto mb-3" />
<p className="text-subtext text-sm mb-3">등록된 소설이 없습니다.</p>
<button
onClick={() => setShowModal(true)}
className="inline-flex items-center gap-1.5 text-sm bg-accent text-bg font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity"
>
<Plus size={14} />
소설 만들기
</button>
</div>
) : (
<div className="space-y-4">
{novels.map(novel => (
<NovelCard key={novel.novel_id} novel={novel} onGenerate={fetchNovels} />
))}
</div>
)}
{showModal && (
<NewNovelModal
onClose={() => setShowModal(false)}
onCreated={() => fetchNovels()}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,253 @@
import React, { useState, useEffect } from 'react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { RefreshCw, CheckCircle2, Loader2, Clock, XCircle, AlertCircle, Zap, CalendarDays } from 'lucide-react'
const STEP_ICONS = {
done: <CheckCircle2 size={16} className="text-success" />,
running: <Loader2 size={16} className="text-info animate-spin" />,
waiting: <Clock size={16} className="text-subtext" />,
error: <XCircle size={16} className="text-error" />,
}
const STEP_LABELS = {
done: '완료',
running: '실행중',
waiting: '대기',
error: '오류',
}
const CORNER_COLORS = ['#c8a84e', '#3a7d5c', '#4a5abf', '#bf3a3a', '#7a5abf', '#5a7abf']
function KpiCard({ label, value, sub, color }) {
return (
<div className="card p-4">
<div className="text-xs text-subtext mb-1">{label}</div>
<div className={`text-2xl font-bold ${color || 'text-text'}`}>{value}</div>
{sub && <div className="text-xs text-subtext mt-1">{sub}</div>}
</div>
)
}
function PipelineStep({ name, status, done_at }) {
return (
<div className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div className="flex items-center gap-2">
{STEP_ICONS[status] || STEP_ICONS.waiting}
<span className="text-sm">{name}</span>
</div>
<div className="flex items-center gap-2">
<span className={`tag ${
status === 'done' ? 'badge-done' :
status === 'running' ? 'badge-running' :
status === 'error' ? 'badge-error' :
'badge-waiting'
}`}>
{STEP_LABELS[status] || '대기'}
</span>
{done_at && <span className="text-xs text-subtext font-mono">{done_at}</span>}
</div>
</div>
)
}
const LOG_LEVEL_COLORS = {
ERROR: 'text-error',
WARNING: 'text-warning',
INFO: 'text-info',
DEBUG: 'text-subtext',
}
export default function Overview() {
const [kpi, setKpi] = useState(null)
const [pipeline, setPipeline] = useState([])
const [activity, setActivity] = useState([])
const [corners, setCorners] = useState([])
const [loading, setLoading] = useState(true)
const [lastUpdated, setLastUpdated] = useState('')
const fetchAll = async () => {
setLoading(true)
try {
const [ovRes, pipRes, actRes] = await Promise.all([
fetch('/api/overview'),
fetch('/api/pipeline'),
fetch('/api/activity'),
])
const ov = await ovRes.json()
const pip = await pipRes.json()
const act = await actRes.json()
setKpi(ov.kpi)
setCorners(ov.corner_ratio || [])
setPipeline(pip.steps || [])
setActivity(act.logs || [])
setLastUpdated(new Date().toLocaleTimeString('ko-KR'))
} catch (e) {
console.error('Overview 로드 실패:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAll()
const timer = setInterval(fetchAll, 60000)
return () => clearInterval(timer)
}, [])
if (loading && !kpi) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-accent" size={32} />
</div>
)
}
const revenue = kpi?.revenue || {}
return (
<div className="p-4 md:p-6 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold text-text">개요 대시보드</h1>
<div className="flex items-center gap-3">
{lastUpdated && (
<span className="text-xs text-subtext">업데이트: {lastUpdated}</span>
)}
<button
onClick={fetchAll}
className="flex items-center gap-1.5 text-xs text-subtext hover:text-accent transition-colors"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
새로고침
</button>
</div>
</div>
{/* KPI 카드 4개 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="오늘 발행"
value={kpi?.today ?? 0}
sub="블로그+SNS"
color={kpi?.today > 0 ? 'text-success' : 'text-subtext'}
/>
<KpiCard
label="이번주 발행"
value={kpi?.this_week ?? 0}
sub="7일 기준"
color="text-accent"
/>
<KpiCard
label="총 글 수"
value={kpi?.total ?? 0}
sub={kpi?.today > 0 ? `+${kpi.today} 오늘` : '누적'}
color="text-text"
/>
<KpiCard
label="수익"
value={revenue.amount != null ? `$${revenue.amount.toFixed(2)}` : '$0.00'}
sub={revenue.status || '대기중'}
color={revenue.amount > 0 ? 'text-success' : 'text-subtext'}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 파이프라인 상태 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-3">파이프라인 상태</h2>
{pipeline.length === 0 ? (
<p className="text-subtext text-sm">로그 데이터 없음</p>
) : (
pipeline.map(step => (
<PipelineStep key={step.id} {...step} />
))
)}
</div>
{/* 코너별 발행 비율 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-3">코너별 발행 비율</h2>
{corners.length === 0 ? (
<p className="text-subtext text-sm">발행 데이터 없음</p>
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={corners} layout="vertical" margin={{ left: 8, right: 16, top: 4, bottom: 4 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fill: '#888880', fontSize: 12 }}
width={70}
/>
<Tooltip
contentStyle={{ background: '#111116', border: '1px solid #222228', borderRadius: 6 }}
labelStyle={{ color: '#e0e0d8' }}
formatter={(v, n, p) => [`${p.payload.count}건 (${v}%)`, '비율']}
/>
<Bar dataKey="ratio" radius={[0, 4, 4, 0]}>
{corners.map((_, idx) => (
<Cell key={idx} fill={CORNER_COLORS[idx % CORNER_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* 빠른 액션 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-3">빠른 액션</h2>
<div className="flex flex-wrap gap-2">
<button
onClick={() => window.location.href = '#content'}
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-bg text-xs font-semibold rounded hover:opacity-80 transition-opacity"
>
<AlertCircle size={13} />
승인 대기 확인
</button>
<button
onClick={async () => {
const r = await fetch('/api/manual-write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
const d = await r.json()
alert(JSON.stringify(d.results?.map(x => `${x.step}: ${x.success ? '성공' : x.error}`), null, 2))
}}
className="flex items-center gap-1.5 px-3 py-2 border border-border text-xs rounded hover:border-accent hover:text-accent transition-colors"
>
<Zap size={13} />
오늘 글감 수동 실행
</button>
<button
onClick={fetchAll}
className="flex items-center gap-1.5 px-3 py-2 border border-border text-xs rounded hover:border-accent hover:text-accent transition-colors"
>
<CalendarDays size={13} />
데이터 새로고침
</button>
</div>
</div>
{/* 최근 활동 로그 */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-accent mb-3">최근 활동</h2>
{activity.length === 0 ? (
<p className="text-subtext text-sm">로그 없음</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{activity.map((log, idx) => (
<div key={idx} className="flex gap-3 text-xs py-1 border-b border-border last:border-0">
<span className="text-subtext font-mono whitespace-nowrap">{log.time}</span>
<span className={`font-mono ${LOG_LEVEL_COLORS[log.level] || 'text-subtext'} w-12 flex-shrink-0`}>
{log.level}
</span>
<span className="text-subtext w-20 flex-shrink-0">[{log.module}]</span>
<span className="text-text truncate">{log.message}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react'
import Connections from './settings/Connections.jsx'
import ToolSelect from './settings/ToolSelect.jsx'
import Distribution from './settings/Distribution.jsx'
import Quality from './settings/Quality.jsx'
import CostMonitor from './settings/CostMonitor.jsx'
const SUB_TABS = [
{ id: 'connections', label: 'AI 연결', component: Connections },
{ id: 'tools', label: '생성도구', component: ToolSelect },
{ id: 'distribution', label: '배포채널', component: Distribution },
{ id: 'quality', label: '품질·스케줄', component: Quality },
{ id: 'cost', label: '비용관리', component: CostMonitor },
]
export default function Settings() {
const [activeSubTab, setActiveSubTab] = useState('connections')
const ActiveSub = SUB_TABS.find(t => t.id === activeSubTab)?.component || Connections
return (
<div className="p-4 md:p-6">
<h1 className="text-lg font-bold mb-4">설정</h1>
{/* 서브탭 */}
<div className="flex gap-1 mb-5 flex-wrap">
{SUB_TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveSubTab(tab.id)}
className={`px-4 py-2 text-xs rounded-lg font-medium transition-colors ${
activeSubTab === tab.id
? 'bg-accent text-bg'
: 'bg-card border border-border text-subtext hover:text-text hover:border-accent/50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* 서브탭 내용 */}
<ActiveSub />
</div>
)
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react'
import { Loader2, CheckCircle2, Circle, RefreshCw, Key, Wifi } from 'lucide-react'
const CATEGORY_LABELS = {
writing: '글쓰기',
tts: 'TTS',
image: '이미지',
video: '영상',
multi: '다목적',
}
function ConnectionCard({ conn, onTest, onSaveKey }) {
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState(null)
const [showKeyInput, setShowKeyInput] = useState(false)
const [keyValue, setKeyValue] = useState('')
const [saving, setSaving] = useState(false)
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
const res = await fetch(`/api/connections/${conn.id}/test`, { method: 'POST' })
const data = await res.json()
setTestResult(data)
} catch (e) {
setTestResult({ success: false, message: e.message })
} finally {
setTesting(false)
}
}
const handleSave = async () => {
if (!keyValue.trim()) return
setSaving(true)
try {
const res = await fetch(`/api/connections/${conn.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: keyValue }),
})
const data = await res.json()
if (data.success) {
setShowKeyInput(false)
setKeyValue('')
onSaveKey()
}
} catch (e) {
alert('저장 실패: ' + e.message)
} finally {
setSaving(false)
}
}
return (
<div className="card p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{conn.connected ? (
<CheckCircle2 size={16} className="text-success flex-shrink-0" />
) : (
<Circle size={16} className="text-subtext flex-shrink-0" />
)}
<div>
<p className="font-medium text-sm text-text">{conn.name}</p>
<p className="text-xs text-subtext">{conn.description}</p>
</div>
</div>
<span className={`tag ${conn.connected ? 'badge-done' : 'badge-waiting'}`}>
{conn.connected ? '연결됨' : '미연결'}
</span>
</div>
{conn.key_masked && (
<p className="text-xs text-subtext font-mono mb-3">: {conn.key_masked}</p>
)}
<div className="flex gap-2">
<button
onClick={handleTest}
disabled={testing || !conn.connected}
className="flex items-center gap-1 text-xs border border-border px-2 py-1 rounded hover:border-accent/50 transition-colors disabled:opacity-40"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <Wifi size={11} />}
연결 테스트
</button>
<button
onClick={() => setShowKeyInput(!showKeyInput)}
className="flex items-center gap-1 text-xs border border-border px-2 py-1 rounded hover:border-accent/50 transition-colors"
>
<Key size={11} />
{conn.connected ? 'API 키 변경' : 'API 키 등록'}
</button>
</div>
{testResult && (
<div className={`mt-2 text-xs px-2 py-1.5 rounded ${testResult.success ? 'bg-success/10 text-success' : 'bg-error/10 text-error'}`}>
{testResult.message}
</div>
)}
{showKeyInput && (
<div className="mt-3 flex gap-2">
<input
type="password"
value={keyValue}
onChange={e => setKeyValue(e.target.value)}
placeholder="API 키 입력..."
className="flex-1 bg-bg border border-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent"
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<button
onClick={handleSave}
disabled={saving}
className="text-xs bg-accent text-bg px-3 py-1.5 rounded font-semibold hover:opacity-80 disabled:opacity-50 transition-opacity"
>
{saving ? <Loader2 size={11} className="animate-spin" /> : '저장'}
</button>
</div>
)}
</div>
)
}
export default function Connections() {
const [connections, setConnections] = useState([])
const [loading, setLoading] = useState(true)
const fetchConnections = async () => {
setLoading(true)
try {
const res = await fetch('/api/connections')
const data = await res.json()
setConnections(data.connections || [])
} catch (e) {
console.error('Connections 로드 실패:', e)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchConnections() }, [])
if (loading) {
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
}
// 카테고리별 그룹
const grouped = {}
connections.forEach(c => {
const cat = c.category || 'other'
if (!grouped[cat]) grouped[cat] = []
grouped[cat].push(c)
})
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-accent">AI 서비스 연결 상태</h3>
<button onClick={fetchConnections} className="text-xs text-subtext hover:text-accent flex items-center gap-1">
<RefreshCw size={12} />
새로고침
</button>
</div>
{Object.entries(grouped).map(([cat, conns]) => (
<div key={cat}>
<h4 className="text-xs text-subtext mb-2 uppercase tracking-wide">
{CATEGORY_LABELS[cat] || cat}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{conns.map(conn => (
<ConnectionCard
key={conn.id}
conn={conn}
onTest={() => {}}
onSaveKey={fetchConnections}
/>
))}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,133 @@
import React, { useState, useEffect } from 'react'
import { Loader2, RefreshCw, AlertTriangle, DollarSign, Cpu } from 'lucide-react'
export default function CostMonitor() {
const [subscriptions, setSubscriptions] = useState([])
const [usage, setUsage] = useState([])
const [totalMonthly, setTotalMonthly] = useState(0)
const [loading, setLoading] = useState(true)
const fetchData = async () => {
setLoading(true)
try {
const [sRes, uRes] = await Promise.all([
fetch('/api/cost/subscriptions'),
fetch('/api/cost/usage'),
])
const sData = await sRes.json()
const uData = await uRes.json()
setSubscriptions(sData.subscriptions || [])
setTotalMonthly(sData.total_monthly_usd || 0)
setUsage(uData.usage || [])
} catch (e) {
console.error('Cost 로드 실패:', e)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
if (loading) {
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-accent flex items-center gap-2">
<DollarSign size={14} />
비용 모니터링
</h3>
<button onClick={fetchData} className="text-xs text-subtext hover:text-accent flex items-center gap-1">
<RefreshCw size={12} />
새로고침
</button>
</div>
{/* 월간 비용 요약 */}
<div className="card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-subtext">예상 월간 고정 비용</p>
<p className="text-2xl font-bold text-accent">${totalMonthly.toFixed(2)}</p>
</div>
<DollarSign size={32} className="text-accent/30" />
</div>
{/* 구독 테이블 */}
<div className="card p-4">
<h4 className="text-sm font-medium text-text mb-3">구독 현황</h4>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-subtext border-b border-border">
<th className="text-left py-2">서비스</th>
<th className="text-left py-2">제공사</th>
<th className="text-center py-2">상태</th>
<th className="text-right py-2"> 비용</th>
<th className="text-right py-2">갱신 D-Day</th>
</tr>
</thead>
<tbody>
{subscriptions.map(sub => (
<tr key={sub.id} className="border-b border-border/50 hover:bg-card-hover">
<td className="py-2 font-medium">{sub.name}</td>
<td className="py-2 text-subtext">{sub.provider}</td>
<td className="py-2 text-center">
<span className={`tag ${sub.active ? 'badge-done' : 'badge-waiting'}`}>
{sub.active ? '활성' : '비활성'}
</span>
</td>
<td className="py-2 text-right">
{sub.monthly_cost_usd > 0 ? `$${sub.monthly_cost_usd.toFixed(2)}` : '종량제'}
</td>
<td className="py-2 text-right">
{sub.days_until_renewal != null ? (
<span className={sub.alert ? 'text-error font-semibold' : 'text-subtext'}>
{sub.alert && <AlertTriangle size={10} className="inline mr-0.5" />}
D-{sub.days_until_renewal}
</span>
) : (
<span className="text-subtext">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* API 사용량 */}
<div className="card p-4">
<h4 className="text-sm font-medium text-text mb-3 flex items-center gap-2">
<Cpu size={13} />
API 사용량 (로그 기반 추정)
</h4>
{usage.length === 0 ? (
<p className="text-subtext text-sm">사용량 데이터 없음 (로그에서 파싱)</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-subtext border-b border-border">
<th className="text-left py-2">제공사</th>
<th className="text-right py-2">토큰 </th>
<th className="text-right py-2">예상 비용</th>
</tr>
</thead>
<tbody>
{usage.map((u, idx) => (
<tr key={idx} className="border-b border-border/50">
<td className="py-2 font-medium capitalize">{u.provider}</td>
<td className="py-2 text-right font-mono">{u.tokens.toLocaleString()}</td>
<td className="py-2 text-right text-accent">${u.estimated_cost_usd.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-xs text-subtext mt-2">* 사용량은 로그 파싱 기반 근사치입니다.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect } from 'react'
import { Loader2, Save, Globe } from 'lucide-react'
const PLATFORMS = [
{ id: 'blogger', label: '블로거 (Blogger)', icon: '📝' },
{ id: 'youtube', label: 'YouTube Shorts', icon: '▶️' },
{ id: 'instagram', label: 'Instagram Reels', icon: '📸' },
{ id: 'x', label: 'X (Twitter)', icon: '🐦' },
{ id: 'tiktok', label: 'TikTok', icon: '🎵' },
{ id: 'novel', label: '노벨피아', icon: '📖' },
]
export default function Distribution() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetch('/api/settings')
.then(r => r.json())
.then(d => setSettings(d))
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const togglePlatform = (id) => {
setSettings(s => ({
...s,
publishing: {
...s.publishing,
[id]: {
...(s.publishing?.[id] || {}),
enabled: !(s.publishing?.[id]?.enabled ?? false),
},
},
}))
}
const updateSchedule = (key, value) => {
setSettings(s => ({
...s,
schedule: { ...s.schedule, [key]: value },
}))
}
const handleSave = async () => {
setSaving(true)
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: settings }),
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
alert('저장 실패: ' + e.message)
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
}
const publishing = settings?.publishing || {}
const schedule = settings?.schedule || {}
return (
<div className="space-y-5">
<h3 className="text-sm font-semibold text-accent flex items-center gap-2">
<Globe size={14} />
배포 채널 설정
</h3>
{/* 플랫폼 ON/OFF */}
<div className="card p-4">
<h4 className="text-sm font-medium text-text mb-3">발행 채널</h4>
<div className="space-y-3">
{PLATFORMS.map(platform => {
const enabled = publishing[platform.id]?.enabled ?? false
return (
<div key={platform.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div className="flex items-center gap-2">
<span>{platform.icon}</span>
<span className="text-sm text-text">{platform.label}</span>
</div>
<button
onClick={() => togglePlatform(platform.id)}
className={`relative w-12 h-6 rounded-full transition-colors ${
enabled ? 'bg-success' : 'bg-border'
}`}
>
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
enabled ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
</div>
)
})}
</div>
</div>
{/* 시차 배포 스케줄 */}
<div className="card p-4">
<h4 className="text-sm font-medium text-text mb-3">발행 시각 설정</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ key: 'collector', label: '수집' },
{ key: 'writer', label: '글쓰기' },
{ key: 'converter', label: '변환' },
{ key: 'publisher', label: '발행' },
{ key: 'youtube_uploader', label: 'YouTube 업로드' },
{ key: 'analytics', label: '분석' },
].map(({ key, label }) => (
<div key={key}>
<label className="block text-xs text-subtext mb-1">{label}</label>
<input
type="time"
value={schedule[key] || ''}
onChange={e => updateSchedule(key, e.target.value)}
className="w-full bg-bg border border-border rounded px-2 py-1.5 text-sm text-text focus:outline-none focus:border-accent"
/>
</div>
))}
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-accent text-bg text-sm font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity disabled:opacity-50"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{saved ? '저장됨!' : '설정 저장'}
</button>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react'
import { Loader2, Save, Shield } from 'lucide-react'
function Slider({ label, value, min, max, onChange, help }) {
const pct = ((value - min) / (max - min)) * 100
const color = value >= 80 ? '#3a7d5c' : value >= 60 ? '#c8a84e' : '#bf3a3a'
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-text">{label}</label>
<span className="text-sm font-bold font-mono" style={{ color }}>{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={e => onChange(Number(e.target.value))}
className="w-full accent-accent"
style={{ accentColor: color }}
/>
<div className="flex justify-between text-xs text-subtext">
<span>{min}</span>
{help && <span>{help}</span>}
<span>{max}</span>
</div>
</div>
)
}
export default function Quality() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetch('/api/settings')
.then(r => r.json())
.then(d => setSettings(d))
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const updateQuality = (key, value) => {
setSettings(s => ({
...s,
quality_gates: { ...s.quality_gates, [key]: value },
}))
}
const updateSchedule = (key, value) => {
setSettings(s => ({
...s,
schedule: { ...s.schedule, [key]: value },
}))
}
const handleSave = async () => {
setSaving(true)
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: settings }),
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
alert('저장 실패: ' + e.message)
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
}
const qg = settings?.quality_gates || {}
return (
<div className="space-y-5">
<h3 className="text-sm font-semibold text-accent flex items-center gap-2">
<Shield size={14} />
품질 기준 설정
</h3>
{/* 품질 점수 슬라이더 */}
<div className="card p-4">
<h4 className="text-sm font-medium text-text mb-4">품질 게이트 점수</h4>
<Slider
label="Gate 1 — 수집 최소 점수"
value={qg.gate1_research_min_score ?? 60}
min={0} max={100}
help="리서치 품질"
onChange={v => updateQuality('gate1_research_min_score', v)}
/>
<Slider
label="Gate 2 — 글쓰기 최소 점수"
value={qg.gate2_writing_min_score ?? 70}
min={0} max={100}
help="글 품질"
onChange={v => updateQuality('gate2_writing_min_score', v)}
/>
<Slider
label="Gate 3 — 자동 승인 점수"
value={qg.gate3_auto_approve_score ?? 90}
min={0} max={100}
help="이 이상이면 자동 승인"
onChange={v => updateQuality('gate3_auto_approve_score', v)}
/>
<Slider
label="최소 핵심 포인트 수"
value={qg.min_key_points ?? 2}
min={1} max={10}
onChange={v => updateQuality('min_key_points', v)}
/>
<Slider
label="최소 단어 수"
value={qg.min_word_count ?? 300}
min={100} max={2000}
onChange={v => updateQuality('min_word_count', v)}
/>
{/* 크로스 리뷰 / 안전 검사 */}
<div className="mt-4 space-y-3 border-t border-border pt-4">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm text-text">Gate 3 검수 필요</span>
<div
className={`relative w-12 h-6 rounded-full transition-colors ${qg.gate3_review_required ? 'bg-success' : 'bg-border'}`}
onClick={() => updateQuality('gate3_review_required', !qg.gate3_review_required)}
>
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${qg.gate3_review_required ? 'translate-x-7' : 'translate-x-1'}`} />
</div>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm text-text">안전 검사 (Safety Check)</span>
<div
className={`relative w-12 h-6 rounded-full transition-colors ${qg.safety_check ? 'bg-success' : 'bg-border'}`}
onClick={() => updateQuality('safety_check', !qg.safety_check)}
>
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${qg.safety_check ? 'translate-x-7' : 'translate-x-1'}`} />
</div>
</label>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-accent text-bg text-sm font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity disabled:opacity-50"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{saved ? '저장됨!' : '설정 저장'}
</button>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import React, { useState, useEffect } from 'react'
import { Loader2, Save } from 'lucide-react'
export default function ToolSelect() {
const [tools, setTools] = useState({})
const [selected, setSelected] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetch('/api/tools')
.then(r => r.json())
.then(d => {
setTools(d.tools || {})
const initial = {}
Object.entries(d.tools || {}).forEach(([k, v]) => {
initial[k] = v.current
})
setSelected(initial)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
try {
await fetch('/api/tools', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tools: selected }),
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
alert('저장 실패: ' + e.message)
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
}
return (
<div className="space-y-5">
<h3 className="text-sm font-semibold text-accent">생성 도구 선택</h3>
{Object.entries(tools).map(([category, data]) => (
<div key={category} className="card p-4">
<h4 className="text-sm font-medium text-text mb-3">{data.label}</h4>
<div className="space-y-2">
{data.options.map(opt => (
<label
key={opt.value}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
selected[category] === opt.value
? 'bg-accent/10 border border-accent/30'
: 'border border-transparent hover:bg-card-hover'
}`}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
selected[category] === opt.value
? 'border-accent'
: 'border-subtext'
}`}>
{selected[category] === opt.value && (
<div className="w-2 h-2 rounded-full bg-accent" />
)}
</div>
<input
type="radio"
name={category}
value={opt.value}
checked={selected[category] === opt.value}
onChange={() => setSelected(s => ({ ...s, [category]: opt.value }))}
className="sr-only"
/>
<span className="text-sm text-text">{opt.label}</span>
</label>
))}
</div>
</div>
))}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-accent text-bg text-sm font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity disabled:opacity-50"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{saved ? '저장됨!' : '설정 저장'}
</button>
</div>
)
}

View File

@@ -0,0 +1,90 @@
/* CNN 다크 + 골드 테마 */
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: #0a0a0d;
--card: #111116;
--border: #222228;
--text: #e0e0d8;
--subtext: #888880;
--accent: #c8a84e;
--success: #3a7d5c;
--warning: #c8a84e;
--error: #bf3a3a;
--info: #4a5abf;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
background-color: var(--bg);
color: var(--text);
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* 스크롤바 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--subtext);
}
/* 골드 액센트 버튼 */
.btn-accent {
background-color: var(--accent);
color: #0a0a0d;
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
transition: opacity 0.2s;
}
.btn-accent:hover {
opacity: 0.85;
}
/* 카드 기본 */
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
/* 태그 */
.tag {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
font-weight: 500;
}
/* 상태 뱃지 */
.badge-done { background: #1a3d2c; color: #4ade80; }
.badge-running { background: #1a2a4d; color: #60a5fa; }
.badge-waiting { background: #1a1a22; color: #888880; }
.badge-error { background: #3d1a1a; color: #f87171; }
/* 칸반 드래그 영역 */
.kanban-col {
min-height: 400px;
}
/* 로그 레벨 */
.log-error { color: #f87171; }
.log-warning { color: #fbbf24; }
.log-info { color: #60a5fa; }
.log-debug { color: #888880; }

View File

@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
bg: '#0a0a0d',
card: '#111116',
border: '#222228',
text: '#e0e0d8',
subtext: '#888880',
accent: '#c8a84e',
'accent-dim': '#8a7236',
success: '#3a7d5c',
warning: '#c8a84e',
error: '#bf3a3a',
info: '#4a5abf',
'card-hover': '#18181f',
},
fontFamily: {
sans: ['Pretendard', 'Apple SD Gothic Neo', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Consolas', 'monospace'],
},
borderColor: {
DEFAULT: '#222228',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

52
dashboard/start.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 > nul
title The 4th Path - Control Panel
echo ================================================
echo The 4th Path · Control Panel
echo ================================================
set SCRIPT_DIR=%~dp0
set PROJECT_ROOT=%SCRIPT_DIR%..
:: Python 가상환경 활성화
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
echo [*] 가상환경 활성화...
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
)
:: 백엔드 의존성 확인
echo [*] FastAPI 의존성 확인...
pip install fastapi uvicorn python-dotenv --quiet 2>nul
:: 프론트엔드 의존성 설치
if not exist "%SCRIPT_DIR%frontend\node_modules" (
echo [*] npm 패키지 설치 중...
cd /d "%SCRIPT_DIR%frontend"
npm install
)
:: 프론트엔드 빌드
echo [*] 프론트엔드 빌드 중...
cd /d "%SCRIPT_DIR%frontend"
npm run build
if errorlevel 1 (
echo [!] 프론트엔드 빌드 실패!
pause
exit /b 1
)
:: 백엔드 서버 시작
echo [*] 대시보드 서버 시작...
echo.
echo 접속 주소: http://localhost:8080
echo 종료하려면 이 창을 닫으세요.
echo.
cd /d "%PROJECT_ROOT%"
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
pause

90
dashboard/start.sh Normal file
View File

@@ -0,0 +1,90 @@
#!/bin/bash
# The 4th Path — Control Panel 시작 스크립트
# 백엔드(FastAPI) + 프론트엔드(Vite dev) 동시 실행
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
FRONTEND_DIR="$SCRIPT_DIR/frontend"
BACKEND_DIR="$SCRIPT_DIR/backend"
echo "================================================"
echo " The 4th Path · Control Panel"
echo " 프로젝트 루트: $PROJECT_ROOT"
echo "================================================"
# Python 가상환경 확인
if [ -d "$PROJECT_ROOT/venv" ]; then
echo "[*] 가상환경 활성화..."
source "$PROJECT_ROOT/venv/bin/activate" 2>/dev/null || source "$PROJECT_ROOT/venv/Scripts/activate" 2>/dev/null
elif [ -d "$PROJECT_ROOT/.venv" ]; then
source "$PROJECT_ROOT/.venv/bin/activate" 2>/dev/null || source "$PROJECT_ROOT/.venv/Scripts/activate" 2>/dev/null
fi
# 백엔드 의존성 확인
echo "[*] 백엔드 의존성 확인..."
cd "$PROJECT_ROOT"
pip install fastapi uvicorn python-dotenv 2>/dev/null || true
# 프론트엔드 의존성 확인
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
echo "[*] 프론트엔드 의존성 설치 (npm install)..."
cd "$FRONTEND_DIR"
npm install
fi
# 프론트 빌드 여부 확인
if [ ! -d "$FRONTEND_DIR/dist" ]; then
echo "[*] 프론트엔드 빌드..."
cd "$FRONTEND_DIR"
npm run build
fi
# 함수: 백엔드 실행
start_backend() {
echo "[*] 백엔드 시작 (http://localhost:8080)..."
cd "$PROJECT_ROOT"
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 --reload &
BACKEND_PID=$!
echo " PID: $BACKEND_PID"
}
# 함수: 프론트엔드 개발 서버 실행
start_frontend_dev() {
echo "[*] 프론트엔드 개발 서버 시작 (http://localhost:5173)..."
cd "$FRONTEND_DIR"
npm run dev &
FRONTEND_PID=$!
echo " PID: $FRONTEND_PID"
}
# 실행 모드 선택
MODE=${1:-"prod"}
if [ "$MODE" = "dev" ]; then
start_backend
start_frontend_dev
echo ""
echo "개발 모드 실행 중:"
echo " 프론트엔드: http://localhost:5173"
echo " 백엔드 API: http://localhost:8080"
echo ""
echo "종료: Ctrl+C"
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
wait
else
# 프로덕션 모드: 프론트 빌드 후 백엔드만 실행
echo "[*] 프론트엔드 빌드..."
cd "$FRONTEND_DIR"
npm run build
start_backend
echo ""
echo "프로덕션 모드 실행 중:"
echo " 대시보드: http://localhost:8080"
echo ""
echo "종료: Ctrl+C"
trap "kill $BACKEND_PID 2>/dev/null; exit" INT TERM
wait $BACKEND_PID
fi

40
dashboard/start_dev.bat Normal file
View File

@@ -0,0 +1,40 @@
@echo off
chcp 65001 > nul
title The 4th Path - Control Panel (개발 모드)
echo ================================================
echo The 4th Path · Control Panel (개발 모드)
echo ================================================
set SCRIPT_DIR=%~dp0
set PROJECT_ROOT=%SCRIPT_DIR%..
:: Python 가상환경 활성화
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
)
:: 백엔드 의존성 확인
pip install fastapi uvicorn python-dotenv --quiet 2>nul
:: 프론트엔드 의존성 설치
if not exist "%SCRIPT_DIR%frontend\node_modules" (
echo [*] npm 패키지 설치 중...
cd /d "%SCRIPT_DIR%frontend"
npm install
)
echo [*] 백엔드 시작 중...
start "FastAPI Backend" cmd /k "cd /d %PROJECT_ROOT% && python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 --reload"
echo [*] 프론트엔드 개발 서버 시작 중...
start "Vite Frontend" cmd /k "cd /d %SCRIPT_DIR%frontend && npm run dev"
echo.
echo 백엔드: http://localhost:8080
echo 프론트: http://localhost:5173 (개발 서버)
echo.
echo 두 창이 열렸습니다. 각 창을 닫으면 서버가 종료됩니다.
pause