h-lab/src/main/resources/templates/shortform.html

328 lines
18 KiB
HTML

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 숏폼 큐</title>
<style>
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.sf-form input { flex: 1; }
.sf-tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--border); margin-bottom:1rem; }
.sf-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--text-2); padding:.7rem 1rem; font:600 .9rem/1 inherit; cursor:pointer; }
.sf-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.sf-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
.sf-category { font-size:.68rem; padding:.18rem .45rem; border-radius:99px; background:var(--surface-2); color:var(--text-2); font-weight:700; }
.sf-category.POLITICS { background:rgba(68, 94, 160, .12); color:#445ea0; }
.sf-paste { display: none; margin-bottom: 1rem; }
.sf-paste.open { display: block; }
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
.sf-job { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.sf-job-head { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; cursor: pointer; }
.sf-job-head img { width: 96px; border-radius: 6px; flex-shrink: 0; }
.sf-job-title { flex: 1; min-width: 0; }
.sf-job-title .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-job-title .u { font-size: 0.75rem; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 600; flex-shrink: 0; }
.sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); }
.sf-badge.DONE { background: rgba(var(--success-rgb), 0.15); color: var(--success); }
.sf-badge.FAILED { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); }
.sf-subtitle { font-size:.7rem; color:var(--text-2); white-space:nowrap; }
.sf-subtitle.READY { color:var(--success); }
.sf-subtitle.FAILED, .sf-subtitle.UNAVAILABLE { color:var(--danger); }
.sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
.sf-job.open .sf-clips { display: block; }
.sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; }
.sf-clip-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; flex-wrap: wrap; }
.sf-clip-head .no { font-weight: 700; }
.sf-clip pre { font-size: 0.72rem; white-space: pre-wrap; background: rgba(127, 127, 127, 0.08); border-radius: 6px; padding: 0.5rem; margin: 0.35rem 0; max-height: 180px; overflow-y: auto; }
</style>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>숏폼 큐</h1>
<p class="sub">유튜브 URL을 등록하면 Opal 숏폼 생성기 결과를 구간별로 저장·조회합니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="togglePaste()">
<i data-lucide="clipboard-paste" style="width:15px;"></i> 결과 붙여넣기
</button>
</div>
</div>
<form class="sf-form" onsubmit="registerJob(event)">
<input type="text" id="urlInput" placeholder="https://www.youtube.com/watch?v=..." required>
<button class="btn btn-primary" type="submit">큐에 등록</button>
</form>
<div class="sf-tabs" role="tablist" aria-label="콘텐츠 분류">
<button class="sf-tab active" id="sfTab-ENTERTAINMENT" role="tab" aria-selected="true"
onclick="switchCategory('ENTERTAINMENT')">예능</button>
<button class="sf-tab" id="sfTab-POLITICS" role="tab" aria-selected="false"
onclick="switchCategory('POLITICS')">정치</button>
</div>
<div class="sf-paste" id="pasteBox">
<input type="text" id="pasteUrl" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
<button class="btn btn-primary" style="margin-top:0.5rem;" onclick="importResult()">파싱해서 저장</button>
</div>
<div id="jobList"></div>
</div>
<th:block layout:fragment="script">
<script>
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
? 'POLITICS' : 'ENTERTAINMENT';
let subtitlePollTimer = null;
const openJobIds = new Set();
async function api(path, opts) {
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
const body = await res.json();
if (!body.success) throw new Error(body.message);
return body.data;
}
function togglePaste() {
document.getElementById('pasteBox').classList.toggle('open');
}
function switchCategory(category) {
currentCategory = category;
document.querySelectorAll('.sf-tab').forEach(tab => {
const active = tab.id === 'sfTab-' + category;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
const q = new URLSearchParams(location.search);
q.set('category', category);
history.replaceState(null, '', location.pathname + '?' + q.toString());
loadJobs();
}
async function registerJob(e) {
e.preventDefault();
const youtubeUrl = document.getElementById('urlInput').value.trim();
try {
await api('/api/shortform/jobs', { method: 'POST',
body: JSON.stringify({ youtubeUrl, category: currentCategory }) });
document.getElementById('urlInput').value = '';
loadJobs();
} catch (err) { alert(err.message); }
}
async function importResult() {
const youtubeUrl = document.getElementById('pasteUrl').value.trim();
const rawText = document.getElementById('pasteRaw').value;
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
try {
await api('/api/shortform/import', { method: 'POST',
body: JSON.stringify({ youtubeUrl, rawText, category: currentCategory }) });
document.getElementById('pasteUrl').value = '';
document.getElementById('pasteRaw').value = '';
togglePaste();
loadJobs();
} catch (err) { alert(err.message); }
}
async function deleteJob(id, e) {
e.stopPropagation();
if (!confirm('이 작업을 삭제할까요?')) return;
await api('/api/shortform/jobs/' + id, { method: 'DELETE' });
openJobIds.delete(id);
loadJobs();
}
// 결과만 지우고 대기로 되돌린다. 등록 URL은 남으므로 큐를 다시 돌리면 그대로 재실행된다.
async function resetJob(id, e) {
e.stopPropagation();
if (!confirm('결과를 지우고 다시 대기 상태로 되돌릴까요?\n'
+ '클립과 Output 원문이 삭제됩니다. 등록된 영상은 그대로 남습니다.')) return;
try {
await api('/api/shortform/jobs/' + id + '/reset', { method: 'POST' });
loadJobs();
} catch (err) { alert('되돌리기 실패: ' + err.message); }
}
async function toggleJob(id, el, restoreOpen = false) {
if (el.classList.contains('open')) {
if (!restoreOpen) {
el.classList.remove('open');
openJobIds.delete(id);
}
return;
}
const detail = await api('/api/shortform/jobs/' + id);
const toolbar = `<div class="sf-clip-head" style="margin-bottom:0.6rem;">
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
data-complete-on-copy>영상 URL 복사</button>
<button class="btn btn-secondary" data-subtitle-copy
data-complete-on-copy ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
<button class="btn btn-secondary" onclick="retrySubtitles(${detail.id}, event)">자막 다시 수집</button>`}
</div>`;
const step1Block = detail.step1Output
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
<summary style="cursor:pointer;font-weight:600;">Step 1: 하이라이트 후보 구간 (선정 이유 포함)</summary>
<pre>${esc(detail.step1Output)}</pre>
</details>`
: '';
el.querySelector('.sf-clips').innerHTML = toolbar + step1Block + (detail.clips.length
? detail.clips.map(clipHtml).join('')
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
el.classList.add('open');
openJobIds.add(id);
el.querySelectorAll('[data-copy]').forEach(btn => {
const label = btn.dataset.label || 'capcut2 JSON 복사';
btn.onclick = async (e) => {
e.stopPropagation();
await navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
if (btn.hasAttribute('data-complete-on-copy')) {
await completeCopiedJob(id, el, btn);
} else {
btn.textContent = '복사됨!';
setTimeout(() => btn.textContent = label, 1200);
}
};
});
const subtitleButton = el.querySelector('[data-subtitle-copy]');
if (subtitleButton && detail.subtitleVtt) {
subtitleButton.onclick = async (e) => {
e.stopPropagation();
await navigator.clipboard.writeText(detail.subtitleVtt);
await completeCopiedJob(id, el, subtitleButton);
};
}
}
function esc(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function completeCopiedJob(id, card, button) {
await api('/api/shortform/jobs/' + id + '/complete', { method: 'POST' });
button.textContent = '완료';
button.disabled = true;
const badge = card.querySelector('.sf-badge');
if (badge) {
badge.className = 'sf-badge DONE';
badge.textContent = 'DONE';
}
}
function clipHtml(c) {
return `<div class="sf-clip">
<div class="sf-clip-head">
<span class="no">ID ${c.clipNo}</span>
<span>${esc(c.titleTop || '')} / <b>${esc(c.titleMain || '(타이틀 파싱 실패)')}</b></span>
<span style="flex:1"></span>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(c.capcutJson || '')}"
${c.capcutJson ? '' : 'disabled'}>capcut2 JSON 복사</button>
</div>
${c.titleCandidates ? `<pre>${esc(c.titleCandidates)}</pre>` : ''}
${c.durationTable ? `<pre>${esc(c.durationTable)}</pre>` : ''}
</div>`;
}
async function retrySubtitles(id, e) {
e.stopPropagation();
try {
await api('/api/shortform/jobs/' + id + '/subtitles/retry', { method: 'POST' });
loadJobs();
} catch (err) { alert('자막 수집 요청 실패: ' + err.message); }
}
async function loadJobs() {
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
const list = document.getElementById('jobList');
list.innerHTML = jobs.length ? '' : '<p style="color:var(--text-3);">등록된 작업이 없습니다.</p>';
jobs.forEach(j => {
const el = document.createElement('div');
el.className = 'sf-job';
el.dataset.jobId = j.id;
el.innerHTML = `<div class="sf-job-head">
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
<div class="sf-job-title">
<div class="t">${esc(j.videoTitle || j.videoId)}</div>
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
<div class="sf-subtitle ${j.subtitleStatus}" data-subtitle-status="${j.subtitleStatus}"
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
</div>
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
<span class="sf-badge ${j.status}">${j.status}</span>
${j.status === 'PENDING' ? '' : `
<button class="btn btn-secondary" onclick="resetJob(${j.id}, event)"
title="결과를 지우고 다시 대기로 — 큐를 돌리면 재실행됩니다">
<i data-lucide="rotate-ccw" style="width:14px;"></i></button>`}
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)"
title="작업 삭제">
<i data-lucide="trash-2" style="width:14px;"></i></button>
</div>
<div class="sf-clips"></div>`;
el.querySelector('.sf-job-head').onclick = () => toggleJob(j.id, el);
list.appendChild(el);
if (openJobIds.has(j.id)) {
toggleJob(j.id, el, true).catch(() => openJobIds.delete(j.id));
}
});
if (window.lucide) lucide.createIcons();
scheduleSubtitlePoll(jobs);
}
function scheduleSubtitlePoll(jobs) {
clearTimeout(subtitlePollTimer);
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) {
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 2000);
}
}
async function pollSubtitleStatuses() {
try {
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
jobs.forEach(j => {
const card = document.querySelector(`.sf-job[data-job-id="${j.id}"]`);
const status = card && card.querySelector('[data-subtitle-status]');
if (!status) return;
const previous = status.dataset.subtitleStatus;
status.dataset.subtitleStatus = j.subtitleStatus;
status.className = 'sf-subtitle ' + j.subtitleStatus;
status.title = j.subtitleError || '';
status.textContent = '자막 ' + subtitleLabel(j);
if (previous === 'COLLECTING' && j.subtitleStatus === 'READY' && card.classList.contains('open')) {
refreshSubtitleCopy(j.id, card);
}
});
scheduleSubtitlePoll(jobs);
} catch (err) {
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 5000);
}
}
async function refreshSubtitleCopy(id, card) {
const detail = await api('/api/shortform/jobs/' + id);
const button = card.querySelector('[data-subtitle-copy]');
if (!button || !detail.subtitleVtt) return;
button.disabled = false;
button.onclick = async (e) => {
e.stopPropagation();
await navigator.clipboard.writeText(detail.subtitleVtt);
await completeCopiedJob(id, card, button);
};
}
function subtitleLabel(job) {
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
UNAVAILABLE:'없음', FAILED:'실패'})[job.subtitleStatus] || '대기';
}
switchCategory(currentCategory);
</script>
</th:block>
</body>
</html>