feat: 숏폼 큐 페이지 — URL 등록·결과 붙여넣기·클립 카드·JSON 복사
사이드바 소재 섹션에 '숏폼 큐' 메뉴 추가. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
db6a9831b6
commit
be96f68f6f
@ -64,6 +64,12 @@ public class WebController {
|
|||||||
return "feed";
|
return "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/shortform")
|
||||||
|
public String shortform(Model model) {
|
||||||
|
model.addAttribute("currentPage", "shortform");
|
||||||
|
return "shortform";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/recommend")
|
@GetMapping("/recommend")
|
||||||
public String recommend(Model model) {
|
public String recommend(Model model) {
|
||||||
model.addAttribute("currentPage", "recommend");
|
model.addAttribute("currentPage", "recommend");
|
||||||
|
|||||||
@ -24,6 +24,9 @@
|
|||||||
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
|
<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>
|
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
|
||||||
</a>
|
</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>
|
<div class="nav-section">분석</div>
|
||||||
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
||||||
|
|||||||
166
src/main/resources/templates/shortform.html
Normal file
166
src/main/resources/templates/shortform.html
Normal 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, '&').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>
|
||||||
Loading…
Reference in New Issue
Block a user