Files
blog-writer/dashboard/frontend/src/pages/Novel.jsx
sinmb79 213f57b52d 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>
2026-03-26 13:17:53 +09:00

304 lines
11 KiB
JavaScript

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