수집함·발굴·대시보드처럼, 썸네일 있는 나머지 화면에서도 썸네일을 클릭하면 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>
220 lines
13 KiB
HTML
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()">×</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;">×</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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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(' · ');
|
|
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>
|