feat(dashboard): count-up, 3-tone bars, rank/ratio badges, click-through deep links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-05-31 00:53:25 +09:00
parent 5ec04eaa60
commit 2d6c962567

View File

@ -22,41 +22,41 @@
<!-- KPI 카드 --> <!-- KPI 카드 -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
<div class="card flex flex-col justify-between"> <a th:href="@{/collection}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">수집 영상</p><h3 class="text-xl font-bold" id="kTotal">-</h3></div> <div><p class="text-sm text-muted">수집 영상</p><h3 class="text-2xl font-bold" id="kTotal">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="library" color="var(--primary)"></i></div> <div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover, rgba(255,255,255,0.06));"><i data-lucide="library" color="var(--primary)"></i></div>
</div> </div>
<div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div> <div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div>
</div> </a>
<div class="card flex flex-col justify-between"> <a th:href="@{/collection(status='NEW')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-xl font-bold" id="kNew">-</h3></div> <div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-2xl font-bold" id="kNew">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="inbox" color="var(--primary)"></i></div> <div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover, rgba(255,255,255,0.06));"><i data-lucide="inbox" color="var(--primary)"></i></div>
</div> </div>
<div class="text-sm text-muted" id="kReviewCap">검토중 -</div> <div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
</div> </a>
<div class="card flex flex-col justify-between"> <a th:href="@{/collection(status='TARGET')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">작업대상 (TARGET)</p><h3 class="text-xl font-bold" id="kTarget">-</h3></div> <div><p class="text-sm text-muted">작업대상 (TARGET)</p><h3 class="text-2xl font-bold" id="kTarget">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="clapperboard" color="var(--primary)"></i></div> <div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover, rgba(255,255,255,0.06));"><i data-lucide="clapperboard" color="var(--primary)"></i></div>
</div> </div>
<div class="text-sm text-muted">재가공 대상</div> <div class="text-sm text-muted">재가공 대상</div>
</div> </a>
<div class="card flex flex-col justify-between"> <a th:href="@{/collection(status='DONE')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">완료 (DONE)</p><h3 class="text-xl font-bold" id="kDone">-</h3></div> <div><p class="text-sm text-muted">완료 (DONE)</p><h3 class="text-2xl font-bold" id="kDone">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="check-circle" color="var(--primary)"></i></div> <div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover, rgba(255,255,255,0.06));"><i data-lucide="check-circle" color="var(--primary)"></i></div>
</div> </div>
<div class="text-sm text-muted" id="kExcludedCap">제외 -</div> <div class="text-sm text-muted" id="kExcludedCap">제외 -</div>
</div> </a>
<div class="card flex flex-col justify-between"> <a th:href="@{/publish}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">발행완료</p><h3 class="text-xl font-bold" id="kPublished">-</h3></div> <div><p class="text-sm text-muted">발행완료</p><h3 class="text-2xl font-bold" id="kPublished">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="send" color="var(--primary)"></i></div> <div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover, rgba(255,255,255,0.06));"><i data-lucide="send" color="var(--primary)"></i></div>
</div> </div>
<div class="text-sm text-muted" id="kPublishCap">대기 - · 작성중 -</div> <div class="text-sm text-muted" id="kPublishCap">대기 - · 작성중 -</div>
</div> </a>
</div> </div>
<!-- 파이프라인 깔때기 --> <!-- 파이프라인 깔때기 -->
@ -66,7 +66,7 @@
<span class="text-sm text-muted" id="funnelExcluded"></span> <span class="text-sm text-muted" id="funnelExcluded"></span>
</div> </div>
<div id="funnel" class="flex flex-col gap-3"> <div id="funnel" class="flex flex-col gap-3">
<p class="text-muted text-sm">로딩 중...</p> <div class="skeleton" style="height:48px;"></div>
</div> </div>
</div> </div>
@ -77,7 +77,7 @@
<h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3> <h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
<a th:href="@{/discover}" class="text-sm text-muted hover:text-white">발굴 →</a> <a th:href="@{/discover}" class="text-sm text-muted hover:text-white">발굴 →</a>
</div> </div>
<div class="flex flex-col gap-2" id="opList"><p class="text-muted text-sm">로딩 중...</p></div> <div class="flex flex-col gap-2" id="opList"><div class="skeleton" style="height:48px;"></div></div>
</div> </div>
<div class="card"> <div class="card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@ -96,26 +96,33 @@
<h3 class="text-lg font-bold">카테고리 분포</h3> <h3 class="text-lg font-bold">카테고리 분포</h3>
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a> <a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
</div> </div>
<div id="catList" class="flex flex-col gap-2"><p class="text-muted text-sm">로딩 중...</p></div> <div id="catList" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
</div> </div>
<div class="card"> <div class="card">
<h3 class="text-lg font-bold mb-4">수집 출처 &amp; 포맷</h3> <h3 class="text-lg font-bold mb-4">수집 출처 &amp; 포맷</h3>
<div id="sourceFormat" class="flex flex-col gap-2"><p class="text-muted text-sm">로딩 중...</p></div> <div id="sourceFormat" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
</div> </div>
</div> </div>
<style>
.bar-track { flex:1; height:10px; background:rgba(255,255,255,0.06); border-radius:999px; overflow:hidden; }
.bar-fill { height:100%; border-radius:999px; }
.st-badge { font-size:0.65rem; padding:1px 7px; border-radius:999px; font-weight:bold; }
</style>
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ /*<![CDATA[*/
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } 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) ? '0' : Number(n).toLocaleString(); } function fmt(n){ return (n==null) ? '0' : Number(n).toLocaleString(); }
function pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; } function pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; }
function countUp(el, target){
target = Number(target||0);
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches){ el.textContent = fmt(target); return; }
const dur = 600, t0 = performance.now();
function tick(now){
const p = Math.min(1, (now - t0) / dur);
const eased = 1 - Math.pow(1 - p, 3);
el.textContent = fmt(Math.round(target * eased));
if (p < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
async function getData(url){ async function getData(url){
const r = await fetch(url); const r = await fetch(url);
const j = await r.json().catch(()=>({})); const j = await r.json().catch(()=>({}));
@ -132,91 +139,104 @@
</div>`; </div>`;
} }
function ratioBadgeClass(r){ const v=Number(r); return v>=10?'badge-danger':(v>=2?'badge-warning':'badge-success'); }
async function loadDashboard(){ async function loadDashboard(){
const refreshIcon = document.querySelector('button[onclick="loadDashboard()"] svg, button[onclick="loadDashboard()"] i');
if (refreshIcon) refreshIcon.classList.add('animate-spin');
let d; let d;
try { d = await getData('/api/dashboard/summary'); } try {
catch(e){ d = await getData('/api/dashboard/summary');
} catch(e) {
document.getElementById('funnel').innerHTML = '<p class="text-danger text-sm">불러오기 실패: '+esc(e.message)+'</p>'; document.getElementById('funnel').innerHTML = '<p class="text-danger text-sm">불러오기 실패: '+esc(e.message)+'</p>';
return; return;
} finally {
if (refreshIcon) refreshIcon.classList.remove('animate-spin');
} }
const pipe = d.pipeline || {}; const pipe = d.pipeline || {};
const bs = pipe.byStatus || {}, src = pipe.bySource || {}; const bs = pipe.byStatus || {}, src = pipe.bySource || {};
const total = Number(pipe.total || 0); const total = Number(pipe.total || 0);
const pub = d.publish || {}, pbs = pub.byStatus || {}; const pub = d.publish || {}, pbs = pub.byStatus || {};
// KPI // KPI (count-up for numbers, textContent for captions)
document.getElementById('kTotal').textContent = fmt(total); countUp(document.getElementById('kTotal'), total);
document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH); document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH);
document.getElementById('kNew').textContent = fmt(bs.NEW); countUp(document.getElementById('kNew'), bs.NEW);
document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING); document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING);
document.getElementById('kTarget').textContent = fmt(bs.TARGET); countUp(document.getElementById('kTarget'), bs.TARGET);
document.getElementById('kDone').textContent = fmt(bs.DONE); countUp(document.getElementById('kDone'), bs.DONE);
document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED); document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED);
document.getElementById('kPublished').textContent = fmt(pbs.PUBLISHED); countUp(document.getElementById('kPublished'), pbs.PUBLISHED);
document.getElementById('kPublishCap').textContent = '대기 ' + fmt(pbs.READY) + ' · 작성중 ' + fmt(pbs.DRAFT); document.getElementById('kPublishCap').textContent = '대기 ' + fmt(pbs.READY) + ' · 작성중 ' + fmt(pbs.DRAFT);
// 깔때기 (총 수집 대비) // 깔때기 (총 수집 대비) — 3-tone color scheme
document.getElementById('funnelExcluded').textContent = '총 ' + fmt(total) + '건 · 제외 ' + fmt(bs.EXCLUDED); document.getElementById('funnelExcluded').textContent = '총 ' + fmt(total) + '건 · 제외 ' + fmt(bs.EXCLUDED);
document.getElementById('funnel').innerHTML = document.getElementById('funnel').innerHTML =
bar('미검토', bs.NEW, total, '#64748b') + bar('미검토', bs.NEW, total, '#64748b') +
bar('검토중', bs.REVIEWING, total, '#38bdf8') + bar('검토중', bs.REVIEWING, total, 'var(--primary)') +
bar('작업대상', bs.TARGET, total, '#f59e0b') + bar('작업대상', bs.TARGET, total, 'var(--primary)') +
bar('완료', bs.DONE, total, '#10b981') + bar('완료', bs.DONE, total, 'var(--success)') +
bar('발행완료', pbs.PUBLISHED, total, '#7C3AED'); bar('발행완료', pbs.PUBLISHED, total, 'var(--success)');
// 떡상 TOP // 떡상 TOP — rank badge + ratio badge
const op = d.outperformers || []; const op = d.outperformers || [];
const opList = document.getElementById('opList'); const opList = document.getElementById('opList');
if(op.length===0){ opList.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; } if(op.length===0){ opList.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; }
else { else {
opList.innerHTML = op.map(v=>{ opList.innerHTML = op.map((v,i)=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-'; 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;">' + 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;">' + '<div class="flex items-center gap-2" style="min-width:0;">' +
'<span class="badge badge-muted" style="flex-shrink:0;">'+(i+1)+'</span>' +
'<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink: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:300px;">'+esc(v.title)+'</div>' + '<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:300px;">'+esc(v.title)+'</div>' +
'<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+fmt(v.viewCount)+'</div></div>' + '<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+fmt(v.viewCount)+'</div></div>' +
'</div>' + '</div>' +
'<span style="color:#ef4444; font-weight:bold; font-size:0.8rem; flex-shrink:0;">'+ratio+'</span></a>'; '<span class="badge '+ratioBadgeClass(v.viewsPerSubRatio)+'" style="flex-shrink:0;">'+ratio+'</span></a>';
}).join(''); }).join('');
} }
// 발행 현황 // 발행 현황 — badge for status
const ptotal = Number(pub.total || 0); const ptotal = Number(pub.total || 0);
document.getElementById('publishBars').innerHTML = document.getElementById('publishBars').innerHTML =
bar('작성중', pbs.DRAFT, ptotal, '#64748b') + bar('작성중', pbs.DRAFT, ptotal, '#64748b') +
bar('발행대기', pbs.READY, ptotal, '#f59e0b') + bar('발행대기', pbs.READY, ptotal, 'var(--primary)') +
bar('발행완료', pbs.PUBLISHED, ptotal, '#10b981'); bar('발행완료', pbs.PUBLISHED, ptotal, 'var(--success)');
const recent = pub.recent || []; const recent = pub.recent || [];
const PUB_ST = { DRAFT:{t:'작성중',c:'#64748b'}, READY:{t:'대기',c:'#f59e0b'}, PUBLISHED:{t:'완료',c:'#10b981'} }; const PUB_ST = {
DRAFT: { t:'작성중', cls:'badge-muted' },
READY: { t:'대기', cls:'badge-warning' },
PUBLISHED: { t:'완료', cls:'badge-success' }
};
document.getElementById('publishRecent').innerHTML = recent.length===0 document.getElementById('publishRecent').innerHTML = recent.length===0
? '<p class="text-muted text-sm" style="margin-top:4px;">발행 패키지가 없습니다.</p>' ? '<p class="text-muted text-sm" style="margin-top:4px;">발행 패키지가 없습니다.</p>'
: '<div class="text-sm text-muted" style="margin:6px 0 2px;">최근</div>' + recent.map(p=>{ : '<div class="text-sm text-muted" style="margin:6px 0 2px;">최근</div>' + recent.map(p=>{
const st = PUB_ST[p.status] || {t:p.status,c:'#64748b'}; const st = PUB_ST[p.status] || { t:p.status, cls:'badge-muted' };
return '<a href="/rework/'+p.channelVideoId+'" class="flex items-center justify-between p-2" style="border-radius:6px; background:rgba(255,255,255,0.03); text-decoration:none;">' + return '<a href="/rework/'+p.channelVideoId+'" class="flex items-center justify-between p-2" style="border-radius:6px; background:rgba(255,255,255,0.03); text-decoration:none;">' +
'<span class="text-sm truncate" style="max-width:170px; color:#e2e8f0;">'+esc(p.title||'(제목 없음)')+'</span>' + '<span class="text-sm truncate" style="max-width:170px; color:#e2e8f0;">'+esc(p.title||'(제목 없음)')+'</span>' +
'<span class="st-badge" style="background:'+st.c+'22; color:'+st.c+'; flex-shrink:0;">'+st.t+'</span></a>'; '<span class="badge '+st.cls+'" style="flex-shrink:0;">'+st.t+'</span></a>';
}).join(''); }).join('');
// 카테고리 분포 // 카테고리 분포 — clickable deep links
const cats = (d.categories && d.categories.categories) || []; const cats = (d.categories && d.categories.categories) || [];
const uncat = (d.categories && d.categories.uncategorized) || 0; const uncat = (d.categories && d.categories.uncategorized) || 0;
const catList = document.getElementById('catList'); const catList = document.getElementById('catList');
if(cats.length===0 && !uncat){ catList.innerHTML = '<p class="text-muted text-sm">분류된 영상이 없습니다.</p>'; } if(cats.length===0 && !uncat){ catList.innerHTML = '<p class="text-muted text-sm">분류된 영상이 없습니다.</p>'; }
else { else {
catList.innerHTML = cats.map(c => bar(c.name, c.count, total, c.color || '#7C3AED')).join('') + catList.innerHTML =
bar('미분류', uncat, total, '#475569'); cats.map(c => '<a href="/collection?categoryId='+c.id+'" style="text-decoration:none; display:block;">'+bar(c.name, c.count, total, 'var(--primary)')+'</a>').join('') +
'<a href="/collection" style="text-decoration:none; display:block;">'+bar('미분류', uncat, total, '#475569')+'</a>';
} }
// 출처 & 포맷 // 출처 & 포맷 — clickable deep links + 3-tone colors
const shorts = Number(pipe.shorts || 0), longForm = Number(pipe.longForm || 0); const shorts = Number(pipe.shorts || 0), longForm = Number(pipe.longForm || 0);
document.getElementById('sourceFormat').innerHTML = document.getElementById('sourceFormat').innerHTML =
'<div class="text-sm text-muted" style="margin-bottom:2px;">출처</div>' + '<div class="text-sm text-muted" style="margin-bottom:2px;">출처</div>' +
bar('채널 수집', src.CHANNEL, total, '#38bdf8') + '<a href="/collection?source=CHANNEL" style="text-decoration:none; display:block;">'+bar('채널 수집', src.CHANNEL, total, 'var(--primary)')+'</a>' +
bar('검색 수집', src.SEARCH, total, '#a78bfa') + '<a href="/collection?source=SEARCH" style="text-decoration:none; display:block;">'+bar('검색 수집', src.SEARCH, total, 'var(--primary)')+'</a>' +
'<div class="text-sm text-muted" style="margin:10px 0 2px;">포맷</div>' + '<div class="text-sm text-muted" style="margin:10px 0 2px;">포맷</div>' +
bar('Shorts', shorts, total, '#ef4444') + '<a href="/collection?shortsOnly=true" style="text-decoration:none; display:block;">'+bar('Shorts', shorts, total, 'var(--danger)')+'</a>' +
bar('롱폼', longForm, total, '#10b981'); '<a href="/collection" style="text-decoration:none; display:block;">'+bar('롱폼', longForm, total, 'var(--success)')+'</a>';
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }