feat(web-ui): SetupTab — YouTube 자동화 구성 허브

This commit is contained in:
2026-05-07 17:25:53 +09:00
parent 4498124514
commit 5bba880c23
2 changed files with 247 additions and 0 deletions

View File

@@ -3180,3 +3180,102 @@
width: 100%; width: 100%;
accent-color: #22c55e; accent-color: #22c55e;
} }
/* === SetupTab === */
.setup-container { display:flex; flex-direction:column; gap:16px; padding:16px; }
.setup-card {
background: rgba(0,0,0,.3);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 14px;
padding: 16px;
}
.setup-card h3 {
margin: 0 0 12px;
font-size: 15px;
color: var(--ms-text, #f0f0f5);
font-family: var(--ms-ff-disp, inherit);
letter-spacing: 0.04em;
}
.setup-card label {
display: block;
margin: 8px 0;
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
}
.setup-card input,
.setup-card textarea,
.setup-card select {
width: 100%;
padding: 8px;
margin-top: 4px;
background: rgba(255,255,255,.04);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 8px;
color: var(--ms-text, #f0f0f5);
font-size: 13px;
font-family: inherit;
box-sizing: border-box;
}
.setup-card input[type="range"] {
padding: 0;
background: transparent;
border: none;
accent-color: var(--ms-accent, #f5a623);
}
.setup-card button {
padding: 6px 14px;
margin-top: 8px;
background: rgba(245, 166, 35, 0.15);
color: var(--ms-accent, #bae6fd);
border: 1px solid rgba(245, 166, 35, 0.4);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
.setup-card button:hover {
background: rgba(245, 166, 35, 0.25);
}
.setup-channel {
display: flex;
align-items: center;
gap: 12px;
}
.setup-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.setup-prompt-row {
display: flex;
gap: 8px;
margin: 6px 0;
align-items: center;
}
.setup-prompt-genre {
width: 80px;
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
flex-shrink: 0;
}
.setup-saving {
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.1);
font-size: 12px;
color: var(--ms-text, #f0f0f5);
z-index: 100;
}
.ms-loading,
.ms-error {
padding: 24px;
text-align: center;
color: var(--ms-muted, #a0a0b0);
}
.ms-error {
color: #f87171;
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import {
getMusicSetup, updateMusicSetup,
getYoutubeAuthUrl, getYoutubeStatus, disconnectYoutube,
} from '../../../api';
export default function SetupTab() {
const [setup, setSetup] = useState(null);
const [yt, setYt] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([getMusicSetup(), getYoutubeStatus()])
.then(([s, y]) => { setSetup(s); setYt(y); })
.catch(e => setError(String(e)));
}, []);
if (!setup) return <p className="ms-loading">Loading</p>;
const save = async (patch) => {
setSaving(true);
try {
const next = await updateMusicSetup(patch);
setSetup(next);
} catch (e) { setError(String(e)); }
finally { setSaving(false); }
};
const connectYoutube = async () => {
const { url } = await getYoutubeAuthUrl();
window.location.href = url;
};
return (
<div className="setup-container">
{error && <div className="ms-error">{error}</div>}
<section className="setup-card">
<h3>YouTube 채널 연동</h3>
{yt && yt.channel_id ? (
<div className="setup-channel">
{yt.avatar_url && <img src={yt.avatar_url} alt="" className="setup-avatar" />}
<span>{yt.channel_title}</span>
<button onClick={async () => { await disconnectYoutube(); setYt({}); }}>
연결 해제
</button>
</div>
) : (
<button className="button primary" onClick={connectYoutube}>
Google 계정 연결
</button>
)}
</section>
<section className="setup-card">
<h3>메타데이터 템플릿</h3>
<label>제목 패턴
<input
value={setup.metadata_template.title}
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, title: e.target.value}}))}
/>
</label>
<label>설명 템플릿
<textarea
rows={6}
value={setup.metadata_template.description}
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, description: e.target.value}}))}
/>
</label>
<label>기본 태그 (쉼표 구분)
<input
value={(setup.metadata_template.tags || []).join(', ')}
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template,
tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean)}}))}
/>
</label>
<button onClick={() => save({ metadata_template: setup.metadata_template })}>저장</button>
</section>
<section className="setup-card">
<h3>AI 커버 prompt (장르별)</h3>
{Object.entries(setup.cover_prompts).map(([g, p]) => (
<div key={g} className="setup-prompt-row">
<span className="setup-prompt-genre">{g}</span>
<input
value={p}
onChange={e => setSetup(s => ({...s, cover_prompts: {...s.cover_prompts, [g]: e.target.value}}))}
/>
</div>
))}
<button onClick={() => save({ cover_prompts: setup.cover_prompts })}>저장</button>
</section>
<section className="setup-card">
<h3>AI 최종 검토 기준</h3>
{['meta','policy','viewer','trend'].map(k => (
<label key={k}>
{k} 가중치 ({setup.review_weights[k]})
<input type="range" min="0" max="100"
value={setup.review_weights[k]}
onChange={e => setSetup(s => ({...s, review_weights: {...s.review_weights, [k]: parseInt(e.target.value)}}))}
/>
</label>
))}
<label>임계값 ({setup.review_threshold})
<input type="range" min="0" max="100" value={setup.review_threshold}
onChange={e => setSetup(s => ({...s, review_threshold: parseInt(e.target.value)}))}
/>
</label>
<button onClick={() => save({ review_weights: setup.review_weights, review_threshold: setup.review_threshold })}>저장</button>
</section>
<section className="setup-card">
<h3>영상 비주얼 기본값</h3>
<label>해상도
<select value={setup.visual_defaults.resolution}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
<option value="1920x1080">1920×1080 (가로)</option>
<option value="1080x1920">1080×1920 (세로/Shorts)</option>
</select>
</label>
<label>스타일
<select value={setup.visual_defaults.style}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, style: e.target.value}}))}>
<option value="visualizer">Visualizer (파형)</option>
</select>
</label>
<button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
</section>
<section className="setup-card">
<h3>발행 정책</h3>
<label>privacy
<select value={setup.publish_policy.privacy}
onChange={e => setSetup(s => ({...s, publish_policy: {...s.publish_policy, privacy: e.target.value}}))}>
<option value="private">Private (비공개)</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
</label>
<button onClick={() => save({ publish_policy: setup.publish_policy })}>저장</button>
</section>
{saving && <div className="setup-saving">저장 ...</div>}
</div>
);
}