feat: 숏폼 큐 페이지 — URL 등록·결과 붙여넣기·클립 카드·JSON 복사

사이드바 소재 섹션에 '숏폼 큐' 메뉴 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-03 12:02:15 +09:00
parent db6a9831b6
commit be96f68f6f
3 changed files with 175 additions and 0 deletions

View File

@ -64,6 +64,12 @@ public class WebController {
return "feed";
}
@GetMapping("/shortform")
public String shortform(Model model) {
model.addAttribute("currentPage", "shortform");
return "shortform";
}
@GetMapping("/recommend")
public String recommend(Model model) {
model.addAttribute("currentPage", "recommend");

View File

@ -24,6 +24,9 @@
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
</a>
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
</a>
<div class="nav-section">분석</div>
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">

View File

@ -0,0 +1,166 @@
<!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);
el.querySelector('.sf-clips').innerHTML = 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 => {
btn.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
btn.textContent = '복사됨!';
setTimeout(() => btn.textContent = 'capcut2 JSON 복사', 1200);
};
});
}
function esc(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>