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
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
View File
@@ -0,0 +1 @@
# dashboard package
+1
View File
@@ -0,0 +1 @@
# dashboard/backend package
+131
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}
+207
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}")
+173
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}
+133
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()}
+109
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,
}
+170
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}
+218
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()}
+43
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}")
+113
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}")
+78
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"}
+15
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>
+23
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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+71
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>
)
}
+10
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>,
)
+231
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>
)
}
+271
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>
)
}
+202
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>
)
}
+303
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>
)
}
+253
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>
)
}
+46
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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+90
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; }
+33
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: [],
}
+20
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
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
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
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