h-lab/src/main/resources/templates/discover.html
hehihoho3@gmail.com 652d25a483 feat(ui): discover table fits viewport (merged 영상 column)
Merge thumbnail+title+channel into one "영상" column like 수집함 so the table
fits without horizontal scroll. Promote .coll-table/.row-sel to global style.css
for reuse across list pages.

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

243 lines
14 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>
</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="trending-up"></i></div>
<div><div class="hi-t">배율(떡상 지표)</div><div class="hi-d"><b>조회수 ÷ 구독자</b>로, 구독자 대비 조회수가 폭발한 정도입니다. 높을수록 ‘떡상’(10x↑ 빨강, 2x↑ 주황). 작은 채널의 대박 영상을 찾는 데 유용합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="filter"></i></div>
<div><div class="hi-t">필터</div><div class="hi-d"><b>기간·최소 배율·출처</b>로 좁히고 <b>정렬</b>을 바꿉니다. 칩 <b>Shorts</b>·<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 class="card mb-4">
<div class="toolbar">
<label class="chip"><input type="checkbox" id="fShorts" onchange="loadVideos()"> Shorts</label>
<label class="chip"><input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> 미처리만</label>
<span class="sep"></span>
<label class="field">기간
<select id="fPeriod" onchange="loadVideos()">
<option value="">전체</option>
<option value="7">최근 7일</option>
<option value="14">최근 14일</option>
<option value="30">최근 30일</option>
</select>
</label>
<label class="field">최소 배율
<select id="fMinRatio" onchange="loadVideos()">
<option value="">전체</option>
<option value="2">≥ 2x</option>
<option value="3">≥ 3x</option>
<option value="5">≥ 5x</option>
</select>
</label>
<label class="field">출처
<select id="fSource" onchange="loadVideos()">
<option value="">전체</option>
<option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option>
</select>
</label>
<label class="field">정렬
<select id="fSort" onchange="loadVideos()">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option>
</select>
</label>
<span class="spacer"></span>
<span id="resultCount" class="badge badge-muted"></span>
<button class="btn btn-secondary" onclick="loadVideos()" title="새로고침"><i data-lucide="refresh-cw" style="width:15px;"></i></button>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full coll-table" style="border-collapse:collapse; text-align:left;">
<thead>
<tr>
<th>영상</th>
<th>구독자</th>
<th>조회수</th>
<th>시간당</th>
<th>배율</th>
<th>업로드</th>
<th>상태</th>
<th style="text-align:right;">관리</th>
</tr>
</thead>
<tbody id="resultBody">
<tr><td colspan="8" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 영상 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
</div>
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
</div>
</div>
<style>
.row-sel { padding:4px 6px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.8rem; outline:none; }
.row-sel option { background:var(--surface); color:var(--text); }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/v1/channel-videos';
const STATUS_LABEL = { NEW:'NEW', REVIEWING:'검토중', TARGET:'작업대상', DONE:'완료', EXCLUDED:'제외' };
const STATUS_KEYS = ['NEW','REVIEWING','TARGET','DONE','EXCLUDED'];
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmt(n){ return (n==null) ? '-' : Number(n).toLocaleString(); }
function fmtDate(s){ return s ? String(s).substring(0,10) : '-'; }
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;
}
async function loadVideos(){
const body = document.getElementById('resultBody');
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>';
const p = new URLSearchParams();
const period = document.getElementById('fPeriod').value;
const minRatio = document.getElementById('fMinRatio').value;
const src = document.getElementById('fSource').value;
if(period) p.set('periodDays', period);
if(minRatio) p.set('minRatio', minRatio);
if(src) p.set('source', src);
if(document.getElementById('fShorts').checked) p.set('shortsOnly','true');
if(document.getElementById('fUnprocessed').checked) p.set('unprocessedOnly','true');
p.set('sortBy', document.getElementById('fSort').value);
p.set('limit', '100');
let data;
try { data = await api(API + '/discover?' + p.toString()); }
catch(e){
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
return;
}
renderRows(data || []);
}
function ratioBadge(r){
if(r==null) return '<span class="text-muted">-</span>';
const v = Number(r);
let color = '#10b981';
if(v>=10) color = '#ef4444'; else if(v>=2) color = '#f59e0b';
return `<span style="color:${color}; font-weight:bold;">${v.toFixed(1)}x</span>`;
}
function renderRows(list){
const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
return;
}
body.innerHTML = list.map(v=>{
const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span class="badge badge-primary" style="margin-left:5px;">Shorts</span>' : '';
return `<tr>
<td>
<div class="flex items-center gap-3" style="min-width:0;">
<img src="${esc(v.thumbnailUrl)}" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)"
style="width:84px; height:47px; object-fit:cover; border-radius:6px; cursor:pointer; flex-shrink:0;">
<div style="min-width:0;">
<div class="font-semibold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.35;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline">${esc(v.title)}</a>${shortsBadge}
</div>
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:var(--text-3); font-size:0.72rem; display:block; margin-top:2px;">${esc(v.channelTitle||'-')}</a>
</div>
</div>
</td>
<td class="text-sm">${fmt(v.subscriberCount)}</td>
<td class="text-sm">${fmt(v.viewCount)}</td>
<td class="text-sm">${fmt(v.viewsPerHour)}</td>
<td class="text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td class="text-sm text-muted">${fmtDate(v.publishedAt)}</td>
<td><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td>
<div class="flex items-center gap-1 justify-end">
<a class="btn btn-primary" style="padding:0.4rem;" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary" style="padding:0.4rem;" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
</div>
</td>
</tr>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
async function setStatus(id, value){
try { await api(API+'/'+id+'/status', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:value})}); }
catch(e){ alert('상태 변경 실패: '+e.message); }
}
async function toggleBookmark(id, current){
try { await api(API+'/'+id+'/bookmark', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({bookmarked: !current})}); loadVideos(); }
catch(e){ alert('북마크 실패: '+e.message); }
}
function openVideoModal(vid, title){
document.getElementById('modalTitle').textContent = title;
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+vid+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').style.display = 'flex';
}
function closeVideoModal(){
document.getElementById('iframeContainer').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeHelp(); closeVideoModal(); } });
loadVideos();
/*]]>*/
</script>
</div>
</body>
</html>