feat(web-ui): PipelineDetailModal + 카드 mini 미리보기
This commit is contained in:
@@ -3341,3 +3341,59 @@
|
||||
.psm-advanced input, .psm-advanced select { width: 100%; padding: 6px 8px;
|
||||
background: rgba(255,255,255,.04); border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 6px; color: var(--ms-text, #f0f0f5); font-size: 12px; }
|
||||
|
||||
/* === Pipeline Detail Modal === */
|
||||
.modal-body--lg { max-width: 720px; max-height: 90vh; overflow-y: auto; }
|
||||
.pdm-header { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
||||
.pdm-header h3 { flex:1; margin:0; }
|
||||
.pdm-badge { padding:2px 8px; background:rgba(56,189,248,.2); color:#bae6fd;
|
||||
border-radius:6px; font-size:11px; }
|
||||
.pdm-close { background:none; border:none; font-size:24px; cursor:pointer;
|
||||
color:var(--ms-muted, #a0a0b0); padding:0 6px; }
|
||||
|
||||
.pdm-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; }
|
||||
.pdm-figure { margin:0; }
|
||||
.pdm-figure img { width:100%; border-radius:8px; display:block; }
|
||||
.pdm-figure figcaption { font-size:11px; color:var(--ms-muted, #a0a0b0); text-align:center; margin-top:4px; }
|
||||
|
||||
.pdm-video { margin-bottom:16px; }
|
||||
.pdm-video video { border-radius:8px; }
|
||||
|
||||
.pdm-section { margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
|
||||
.pdm-section:last-of-type { border-bottom:none; }
|
||||
.pdm-section h4 { margin:0 0 8px; font-size:14px; }
|
||||
.pdm-pre { background:rgba(0,0,0,.3); padding:8px; border-radius:6px; font-size:12px;
|
||||
white-space:pre-wrap; overflow-x:auto; max-height:400px; }
|
||||
|
||||
.pdm-verdict { padding:2px 8px; margin-left:8px; border-radius:6px; font-size:12px; font-weight:bold; }
|
||||
.pdm-verdict--pass { background:rgba(34,197,94,.2); color:#86efac; }
|
||||
.pdm-verdict--fail { background:rgba(248,113,113,.2); color:#fca5a5; }
|
||||
.pdm-score { color:var(--ms-muted, #a0a0b0); font-size:12px; margin-left:8px; font-weight:normal; }
|
||||
.pdm-review-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.pdm-review-table td { padding:4px 8px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
|
||||
.pdm-review-table td:nth-child(2) { text-align:right; font-weight:bold; }
|
||||
.pdm-summary { font-size:12px; color:var(--ms-muted, #a0a0b0); margin-top:8px; }
|
||||
|
||||
.pdm-tracks { padding-left:24px; }
|
||||
.pdm-tracks li { margin-bottom:4px; font-size:13px; }
|
||||
.pdm-track-time { color:var(--ms-accent, #38bdf8); font-family:monospace; }
|
||||
.pdm-track-dur { color:var(--ms-muted, #a0a0b0); font-size:11px; }
|
||||
|
||||
.pdm-feedback { padding-left:0; list-style:none; }
|
||||
.pdm-feedback li { padding:6px 8px; background:rgba(0,0,0,.2); border-radius:6px;
|
||||
margin-bottom:4px; font-size:12px; }
|
||||
.pdm-feedback code { color:#fb923c; font-size:11px; }
|
||||
.pdm-feedback small { display:block; color:var(--ms-muted, #a0a0b0); margin-top:2px; }
|
||||
|
||||
.pdm-youtube { display:inline-block; padding:8px 16px; background:#ff0000; color:white;
|
||||
border-radius:8px; text-decoration:none; font-weight:bold; }
|
||||
|
||||
/* PipelineCard mini previews + style badge */
|
||||
.pipeline-previews { display:flex; gap:8px; margin:8px 0; align-items:center; }
|
||||
.pipeline-preview-mini { width:64px; height:64px; object-fit:cover; border-radius:6px;
|
||||
border:1px solid var(--ms-line, #2a2a3a); }
|
||||
.pipeline-video-icon { font-size:24px; color:var(--ms-accent, #38bdf8); margin-left:4px; }
|
||||
.pipeline-style-badge { padding:1px 6px; background:rgba(56,189,248,.15); color:#bae6fd;
|
||||
border-radius:4px; font-size:10px; }
|
||||
.pipeline-card { cursor:pointer; }
|
||||
.pipeline-card:hover { background:rgba(255,255,255,.02); }
|
||||
|
||||
@@ -1,63 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { cancelPipeline, publishPipeline } from '../../../api';
|
||||
import PipelineDetailModal from './PipelineDetailModal';
|
||||
|
||||
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
|
||||
|
||||
function stepIndex(state) {
|
||||
if (!state) return -1;
|
||||
if (state.startsWith('cover')) return 0;
|
||||
if (state.startsWith('video')) return 1;
|
||||
if (state.startsWith('thumb')) return 2;
|
||||
if (state.startsWith('meta')) return 3;
|
||||
if (state.startsWith('ai_review') || state.startsWith('publish_pending')) return 4;
|
||||
if (state.startsWith('ai_review') || state === 'publish_pending') return 4;
|
||||
if (state.startsWith('publish')) return 5;
|
||||
if (state === 'published') return 6;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export default function PipelineCard({ pipeline, onChanged }) {
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const i = stepIndex(pipeline.state);
|
||||
const title = pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`;
|
||||
|
||||
const handleCardClick = (e) => {
|
||||
if (e.target.closest('button') || e.target.closest('a')) return;
|
||||
setShowDetail(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pipeline-card">
|
||||
<div className="pipeline-card__head">
|
||||
<h4>{pipeline.track_title || `Track #${pipeline.track_id}`}</h4>
|
||||
{!['published','cancelled','failed'].includes(pipeline.state) && (
|
||||
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
|
||||
취소
|
||||
<>
|
||||
<div className="pipeline-card" onClick={handleCardClick}>
|
||||
<div className="pipeline-card__head">
|
||||
<h4>{title}</h4>
|
||||
{pipeline.visual_style && (
|
||||
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
|
||||
)}
|
||||
{!['published','cancelled','failed'].includes(pipeline.state) && (
|
||||
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-previews">
|
||||
{pipeline.cover_url && (
|
||||
<img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />
|
||||
)}
|
||||
{pipeline.thumbnail_url && (
|
||||
<img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />
|
||||
)}
|
||||
{pipeline.video_url && <span className="pipeline-video-icon">▶</span>}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-progress">
|
||||
{STEP_LABELS.map((lbl, idx) => (
|
||||
<div key={lbl}
|
||||
className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
|
||||
<span>{lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-state">현재: {pipeline.state}</div>
|
||||
|
||||
{pipeline.review && (
|
||||
<div className="pipeline-review">
|
||||
AI 검토: <strong>{pipeline.review.verdict}</strong>
|
||||
({pipeline.review.weighted_total}/100)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pipeline.state === 'publish_pending' && (
|
||||
<button className="button primary"
|
||||
onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
|
||||
YouTube 업로드
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||
target="_blank" rel="noreferrer">
|
||||
유튜브에서 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="pipeline-progress">
|
||||
{STEP_LABELS.map((lbl, idx) => (
|
||||
<div key={lbl} className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
|
||||
<span>{lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pipeline-state">현재: {pipeline.state}</div>
|
||||
{pipeline.review && (
|
||||
<div className="pipeline-review">
|
||||
AI 검토: <strong>{pipeline.review.verdict}</strong>
|
||||
({pipeline.review.weighted_total}/100)
|
||||
</div>
|
||||
)}
|
||||
{pipeline.state === 'publish_pending' && (
|
||||
<button className="button primary"
|
||||
onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
|
||||
YouTube 업로드
|
||||
</button>
|
||||
)}
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`} target="_blank" rel="noreferrer">
|
||||
유튜브에서 보기
|
||||
</a>
|
||||
)}
|
||||
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||
<details className="pipeline-feedback">
|
||||
<summary>피드백 히스토리 ({pipeline.feedback.length})</summary>
|
||||
{pipeline.feedback.map(f => (
|
||||
<div key={f.id}>• [{f.step}] {f.feedback_text}</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetail && <PipelineDetailModal pipeline={pipeline} onClose={() => setShowDetail(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
117
src/pages/music/components/PipelineDetailModal.jsx
Normal file
117
src/pages/music/components/PipelineDetailModal.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
const fmtTimestamp = (sec) => {
|
||||
if (sec == null) return '';
|
||||
const total = Math.floor(sec);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
return `${m}:${String(s).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
export default function PipelineDetailModal({ pipeline, onClose }) {
|
||||
if (!pipeline) return null;
|
||||
const meta = pipeline.metadata || {};
|
||||
const review = pipeline.review || {};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-body modal-body--lg" onClick={e => e.stopPropagation()}>
|
||||
<header className="pdm-header">
|
||||
<h3>{pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`}</h3>
|
||||
<span className="pdm-badge">{pipeline.visual_style || 'essential'}</span>
|
||||
<button onClick={onClose} className="pdm-close" aria-label="close">×</button>
|
||||
</header>
|
||||
|
||||
<div className="pdm-grid">
|
||||
{pipeline.cover_url && (
|
||||
<figure className="pdm-figure">
|
||||
<img src={pipeline.cover_url} alt="cover" />
|
||||
<figcaption>커버 (배경)</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
{pipeline.thumbnail_url && (
|
||||
<figure className="pdm-figure">
|
||||
<img src={pipeline.thumbnail_url} alt="thumbnail" />
|
||||
<figcaption>썸네일</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pipeline.video_url && (
|
||||
<div className="pdm-video">
|
||||
<video src={pipeline.video_url} controls preload="metadata" width="100%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.title && (
|
||||
<section className="pdm-section">
|
||||
<h4>메타데이터</h4>
|
||||
<p><strong>제목:</strong> {meta.title}</p>
|
||||
<details>
|
||||
<summary>설명 ({(meta.description || '').length}자)</summary>
|
||||
<pre className="pdm-pre">{meta.description}</pre>
|
||||
</details>
|
||||
<p><strong>태그:</strong> {(meta.tags || []).join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{review.weighted_total != null && (
|
||||
<section className="pdm-section">
|
||||
<h4>
|
||||
AI 검토
|
||||
<span className={`pdm-verdict pdm-verdict--${review.verdict}`}>
|
||||
{review.verdict}
|
||||
</span>
|
||||
<span className="pdm-score">({review.weighted_total}/100)</span>
|
||||
</h4>
|
||||
<table className="pdm-review-table">
|
||||
<tbody>
|
||||
<tr><td>메타데이터 품질</td><td>{review.metadata_quality?.score}</td></tr>
|
||||
<tr><td>콘텐츠 정책</td><td>{review.policy_compliance?.score}</td></tr>
|
||||
<tr><td>시청 경험</td><td>{review.viewer_experience?.score}</td></tr>
|
||||
<tr><td>트렌드 정렬</td><td>{review.trend_alignment?.score}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{review.summary && <p className="pdm-summary"><em>{review.summary}</em></p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pipeline.tracks && pipeline.tracks.length > 1 && (
|
||||
<section className="pdm-section">
|
||||
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
|
||||
<ol className="pdm-tracks">
|
||||
{pipeline.tracks.map(t => (
|
||||
<li key={t.id}>
|
||||
<span className="pdm-track-time">[{fmtTimestamp(t.start_offset_sec)}]</span>
|
||||
{' '}{t.title}
|
||||
<span className="pdm-track-dur"> ({fmtTimestamp(t.duration_sec)})</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||
<section className="pdm-section">
|
||||
<h4>피드백 히스토리 ({pipeline.feedback.length})</h4>
|
||||
<ul className="pdm-feedback">
|
||||
{pipeline.feedback.map(f => (
|
||||
<li key={f.id}>
|
||||
<code>[{f.step}]</code> {f.feedback_text}
|
||||
<small> {(f.received_at || '').replace('T', ' ')}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||
target="_blank" rel="noreferrer" className="pdm-youtube">
|
||||
🎬 YouTube에서 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user