120 lines
7.0 KiB
HTML
120 lines
7.0 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 - Dashboard</title>
|
|
</head>
|
|
|
|
<body>
|
|
<div layout:fragment="content">
|
|
<header class="mb-4">
|
|
<h1 class="text-xl font-bold mb-4">Dashboard</h1>
|
|
<p class="text-muted">Overview of your YouTube analytics and tracking.</p>
|
|
</header>
|
|
|
|
<!-- Stats Grid -->
|
|
<div class="flex gap-4 mb-4"
|
|
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
|
<div class="card flex flex-col justify-between">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div>
|
|
<p class="text-sm text-muted">수집 영상</p>
|
|
<h3 class="text-xl font-bold" id="statTotal">-</h3>
|
|
</div>
|
|
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
|
<i data-lucide="library" color="var(--primary)"></i>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-muted" id="statSourceCaption">채널 + 검색 수집</div>
|
|
</div>
|
|
|
|
<div class="card flex flex-col justify-between">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div>
|
|
<p class="text-sm text-muted">작업대상 (TARGET)</p>
|
|
<h3 class="text-xl font-bold" id="statTarget">-</h3>
|
|
</div>
|
|
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
|
<i data-lucide="clapperboard" color="var(--primary)"></i>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-muted" id="statReviewCaption">검토중 -</div>
|
|
</div>
|
|
|
|
<div class="card flex flex-col justify-between">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div>
|
|
<p class="text-sm text-muted">미검토 (NEW)</p>
|
|
<h3 class="text-xl font-bold" id="statNew">-</h3>
|
|
</div>
|
|
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
|
<i data-lucide="inbox" color="var(--primary)"></i>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-muted" id="statExcludedCaption">제외 -</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
|
|
<div class="card">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
|
|
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
|
|
</div>
|
|
<div class="flex flex-col gap-2" id="opList">
|
|
<p class="text-muted text-sm">로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h3 class="text-lg font-bold mb-4">수집 출처</h3>
|
|
<div class="flex flex-col gap-2" id="sourceList">
|
|
<p class="text-muted text-sm">로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script th:inline="javascript">
|
|
/*<![CDATA[*/
|
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
async function getData(url){ const r = await fetch(url); const j = await r.json().catch(()=>({})); return j.data; }
|
|
|
|
(async ()=>{
|
|
try {
|
|
const s = await getData('/api/v1/channel-videos/stats');
|
|
if(s){
|
|
document.getElementById('statTotal').textContent = Number(s.total||0).toLocaleString();
|
|
const bs = s.byStatus||{}, src = s.bySource||{};
|
|
document.getElementById('statTarget').textContent = Number(bs.TARGET||0).toLocaleString();
|
|
document.getElementById('statNew').textContent = Number(bs.NEW||0).toLocaleString();
|
|
document.getElementById('statReviewCaption').textContent = '검토중 ' + (bs.REVIEWING||0);
|
|
document.getElementById('statExcludedCaption').textContent = '제외 ' + (bs.EXCLUDED||0);
|
|
document.getElementById('statSourceCaption').textContent = '채널 ' + (src.CHANNEL||0) + ' · 검색 ' + (src.SEARCH||0);
|
|
document.getElementById('sourceList').innerHTML =
|
|
'<div class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03);"><span class="text-sm">채널 수집</span><span class="text-sm font-bold">'+(src.CHANNEL||0)+'</span></div>' +
|
|
'<div class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03);"><span class="text-sm">검색 수집</span><span class="text-sm font-bold">'+(src.SEARCH||0)+'</span></div>';
|
|
}
|
|
} catch(e){ console.error(e); }
|
|
|
|
try {
|
|
const op = await getData('/api/v1/channel-videos/outperformers?limit=5&minRatio=1') || [];
|
|
const list = document.getElementById('opList');
|
|
if(op.length===0){ list.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; return; }
|
|
list.innerHTML = op.map(v=>{
|
|
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-';
|
|
return '<a href="https://www.youtube.com/watch?v='+v.videoId+'" target="_blank" class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03); text-decoration:none;">' +
|
|
'<div class="flex items-center gap-2" style="min-width:0;">' +
|
|
'<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink:0;">' +
|
|
'<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:280px;">'+esc(v.title)+'</div>' +
|
|
'<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+Number(v.viewCount||0).toLocaleString()+'</div></div>' +
|
|
'</div>' +
|
|
'<span style="color:#ef4444; font-weight:bold; font-size:0.8rem; flex-shrink:0;">'+ratio+'</span></a>';
|
|
}).join('');
|
|
} catch(e){ console.error(e); }
|
|
})();
|
|
/*]]>*/
|
|
</script>
|
|
</div>
|
|
</body>
|
|
|
|
</html> |