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
+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,
},
})