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>
243 lines
14 KiB
HTML
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()">×</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;">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
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>
|