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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user