JobDetail에 rawOutput 노출, 카드 펼침 상단에 전체 복사·영상 URL 복사 툴바 추가. capcut2가 구분선·타이틀 후보 섞인 통짜 텍스트에서 JSON 5개를 알아서 뽑고 타이틀은 자체 선택 화면에서 고르므로 원문 그대로 복사한다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
175 lines
9.4 KiB
HTML
175 lines
9.4 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-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-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-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>
|
|
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');
|
|
}
|
|
|
|
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 }) });
|
|
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 }) });
|
|
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' });
|
|
loadJobs();
|
|
}
|
|
|
|
async function toggleJob(id, el) {
|
|
if (el.classList.contains('open')) { el.classList.remove('open'); 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-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
|
|
data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)"
|
|
${detail.rawOutput ? '' : 'disabled'}>전체 Output 복사 (capcut2 오팔 JSON 탭용)</button>
|
|
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
|
|
data-label="영상 URL 복사">영상 URL 복사</button>
|
|
</div>`;
|
|
el.querySelector('.sf-clips').innerHTML = toolbar + (detail.clips.length
|
|
? detail.clips.map(clipHtml).join('')
|
|
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
|
|
el.classList.add('open');
|
|
el.querySelectorAll('[data-copy]').forEach(btn => {
|
|
const label = btn.dataset.label || 'capcut2 JSON 복사';
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
|
|
btn.textContent = '복사됨!';
|
|
setTimeout(() => btn.textContent = label, 1200);
|
|
};
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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 loadJobs() {
|
|
const jobs = await api('/api/shortform/jobs');
|
|
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.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.title || j.videoId)}</div>
|
|
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
|
</div>
|
|
<span class="sf-badge ${j.status}">${j.status}</span>
|
|
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)">
|
|
<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 (window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
loadJobs();
|
|
</script>
|
|
</th:block>
|
|
</body>
|
|
</html>
|