h-lab/src/main/resources/templates/board.html
hehihoho3@gmail.com c0ade287c2 feat(ui): 썸네일 클릭 미리보기를 칸반·채널상세·프로덕션상세에 추가
수집함·발굴·대시보드처럼, 썸네일 있는 나머지 화면에서도 썸네일을 클릭하면
YouTube 9:16 미리보기 팝업이 뜨도록 통일.
- 칸반(board): 카드 썸네일 클릭(드래그와 분리 위해 stopPropagation)
- 채널상세(channel_detail): 영상 목록 썸네일(제목 링크는 YouTube 유지)
- 프로덕션상세(production_detail): videoUrl에서 11자리 id 추출해 임베드,
  id 없으면 새 탭 폴백
각 페이지에 동일 모달(배경/ESC 닫힘, 닫을 때 iframe 비워 재생 중지) 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:18:09 +09:00

220 lines
13 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>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>칸반 보드</h1>
<p class="sub">카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
<button class="btn btn-secondary" onclick="loadBoard()"><i data-lucide="refresh-cw" style="width:15px;"></i> 새로고침</button>
</div>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 칸반 보드 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="columns-3"></i></div>
<div><div class="hi-t">단계(컬럼)</div><div class="hi-d"><b>수집됨 → 검토중 → 작업대상 → 완료</b> 순으로 영상의 진행 상태를 나타냅니다. <b>제외</b>는 작업하지 않을 영상입니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="move"></i></div>
<div><div class="hi-t">드래그로 단계 이동</div><div class="hi-d">카드를 <b>끌어다 다른 컬럼에 놓으면</b> 상태가 바로 바뀝니다(저장됨). 상단 숫자는 컬럼별 카드 수입니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="wand-2"></i></div>
<div><div class="hi-t">카드</div><div class="hi-d">썸네일·제목·지표(조회·시간당·길이)와 배율을 보여줍니다. <b>🪄 아이콘</b>으로 재가공 에디터로 이동합니다.</div></div>
</div>
</div>
</div>
</div>
<div id="board" style="display:grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap:1rem; align-items:start; overflow-x:auto;">
<!-- columns injected -->
</div>
<!-- 영상 미리보기 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; inset:0; background:rgba(0,0,0,0.85); align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this) closeVideoModal()">
<div style="background:var(--surface); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:14px; padding:11px 14px; border-bottom:1px solid var(--border);">
<span id="vmTitle" class="text-sm font-bold truncate" style="max-width:300px;">미리보기</span>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:24px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
</div>
<div id="vmFrame" style="width:min(360px,90vw); aspect-ratio:9/16; max-height:78vh; background:#000;"></div>
</div>
</div>
<style>
#vmFrame iframe { width:100%; height:100%; border:0; display:block; }
.kb-col { background: var(--surface-2); border:1px solid var(--border); border-radius: var(--r); display:flex; flex-direction:column; min-height:200px; }
.kb-col-head { padding:12px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; position:sticky; top:0; background:var(--surface-2); border-radius:var(--r) var(--r) 0 0; }
.kb-body { padding:10px; display:flex; flex-direction:column; gap:8px; min-height:120px; flex:1; transition: background 0.15s; }
.kb-body.drag-over { background: var(--accent-soft); }
.kb-card { background: var(--surface); border:1px solid var(--border); border-radius:9px; padding:9px; cursor:grab; box-shadow:var(--shadow); transition: transform 0.1s, border-color 0.15s; }
.kb-card:hover { border-color: var(--accent); }
.kb-card.dragging { opacity:0.5; }
.kb-count { font-size:0.75rem; font-weight:600; background:var(--surface); border:1px solid var(--border); color:var(--text-2); padding:2px 9px; border-radius:999px; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/v1/channel-videos';
const COLS = [
{ key:'NEW', label:'수집됨', color:'#64748b' },
{ key:'REVIEWING', label:'검토중', color:'#3b82f6' },
{ key:'TARGET', label:'작업대상', color:'#f59e0b' },
{ key:'DONE', label:'완료', color:'#10b981' },
{ key:'EXCLUDED', label:'제외', color:'#ef4444' }
];
let dragId = null;
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
function durBadge(v){
if(v.isShorts) return 'Shorts';
const s = v.durationSec;
if(s==null || s<=0) return '';
const m = Math.floor(s/60), sec = s%60;
return m+':'+String(sec).padStart(2,'0');
}
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
return json.data;
}
function cardHtml(v){
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '';
const ratioColor = (v.viewsPerSubRatio>=10)?'#ef4444':((v.viewsPerSubRatio>=2)?'#f59e0b':'#10b981');
const db = durBadge(v);
const metrics = [
v.viewCount!=null ? '👁 '+fmt(v.viewCount) : '',
v.viewsPerHour!=null ? '⚡ '+fmt(Math.round(v.viewsPerHour))+'/h' : '',
db ? '⏱ '+db : ''
].filter(Boolean).join('&nbsp;&nbsp;·&nbsp;&nbsp;');
return `<div class="kb-card" draggable="true" data-id="${v.id}">
<div style="display:flex; gap:8px;">
<img src="${esc(v.thumbnailUrl)}" title="미리보기" data-vid="${v.videoId}" data-title="${esc(v.title)}"
onclick="event.stopPropagation(); openVideoModal(this.dataset.vid, this.dataset.title)"
style="width:64px; height:36px; object-fit:cover; border-radius:4px; flex-shrink:0; cursor:pointer;">
<div style="min-width:0; flex:1;">
<div class="text-sm font-bold" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.3;">${esc(v.title)}</div>
</div>
</div>
${metrics?`<div class="text-muted mt-2" style="font-size:0.68rem; line-height:1.4;">${metrics}</div>`:''}
<div class="flex items-center justify-between mt-2">
<span class="text-muted" style="font-size:0.7rem;">${esc(v.channelTitle||'')}</span>
<div class="flex items-center gap-2">
${ratio?`<span style="font-size:0.72rem; font-weight:bold; color:${ratioColor};">${ratio}</span>`:''}
<a href="/rework/${v.id}" title="재가공" class="text-muted hover:text-white"><i data-lucide="wand-2" style="width:14px;"></i></a>
</div>
</div>
</div>`;
}
async function loadBoard(){
const board = document.getElementById('board');
let data;
try { data = await api(API + '?sortBy=viewsPerSubRatio') || []; }
catch(e){ board.innerHTML = '<p class="text-danger">불러오기 실패: '+esc(e.message)+'</p>'; return; }
const groups = {}; COLS.forEach(c => groups[c.key] = []);
data.forEach(v => { const k = v.interestStatus || 'NEW'; (groups[k] || (groups[k]=[])).push(v); });
board.innerHTML = COLS.map(c => {
const items = groups[c.key] || [];
return `<div class="kb-col">
<div class="kb-col-head" style="border-top:3px solid ${c.color};">
<span class="font-bold text-sm">${c.label}</span>
<span class="kb-count">${items.length}</span>
</div>
<div class="kb-body" data-status="${c.key}">
${items.map(cardHtml).join('')}
</div>
</div>`;
}).join('');
wireDnd();
if(window.lucide) lucide.createIcons();
}
function wireDnd(){
document.querySelectorAll('.kb-card').forEach(card => {
card.addEventListener('dragstart', e => {
dragId = card.dataset.id;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
});
document.querySelectorAll('.kb-body').forEach(body => {
body.addEventListener('dragover', e => { e.preventDefault(); body.classList.add('drag-over'); });
body.addEventListener('dragleave', () => body.classList.remove('drag-over'));
body.addEventListener('drop', async e => {
e.preventDefault();
body.classList.remove('drag-over');
if(!dragId) return;
const newStatus = body.dataset.status;
const card = document.querySelector('.kb-card[data-id="'+dragId+'"]');
const fromBody = card ? card.parentElement : null;
if(card && fromBody !== body){
body.appendChild(card); // optimistic move
updateCounts();
try {
await api(API + '/' + dragId + '/status', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status: newStatus })
});
if(window.lucide) lucide.createIcons();
} catch(err){
alert('상태 변경 실패: ' + err.message);
if(fromBody) fromBody.appendChild(card); // rollback
updateCounts();
}
}
dragId = null;
});
});
}
function updateCounts(){
document.querySelectorAll('.kb-col').forEach(col => {
const cnt = col.querySelector('.kb-body').querySelectorAll('.kb-card').length;
col.querySelector('.kb-count').textContent = cnt;
});
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
function openVideoModal(vid, title){
document.getElementById('vmTitle').textContent = title || '미리보기';
document.getElementById('vmFrame').innerHTML = '<iframe src="https://www.youtube.com/embed/'+vid+'?autoplay=1" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').style.display = 'flex';
}
function closeVideoModal(){
document.getElementById('vmFrame').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeHelp(); closeVideoModal(); } });
loadBoard();
/*]]>*/
</script>
</div>
</body>
</html>