fix(dashboard): null-ratio badge color, escape categoryId, robust refresh selector, animate once

This commit is contained in:
hehihoho3@gmail.com 2026-05-31 00:59:20 +09:00
parent 2d6c962567
commit 8fe90e431f

View File

@ -14,7 +14,7 @@
<h1 class="text-xl font-bold mb-1">Dashboard</h1> <h1 class="text-xl font-bold mb-1">Dashboard</h1>
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p> <p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
</div> </div>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()"> <button id="refreshBtn" class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()">
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침 <i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
</button> </button>
</div> </div>
@ -110,6 +110,8 @@
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; }
let dashboardAnimated = false;
function countUp(el, target){ function countUp(el, target){
target = Number(target||0); target = Number(target||0);
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches){ el.textContent = fmt(target); return; } if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches){ el.textContent = fmt(target); return; }
@ -123,6 +125,12 @@
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
function setKpi(id, value){
const el = document.getElementById(id);
if(!el) return;
if(dashboardAnimated) el.textContent = fmt(value); else countUp(el, value);
}
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(()=>({}));
@ -142,7 +150,7 @@
function ratioBadgeClass(r){ const v=Number(r); return v>=10?'badge-danger':(v>=2?'badge-warning':'badge-success'); } 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'); const refreshIcon = document.querySelector('#refreshBtn svg, #refreshBtn i');
if (refreshIcon) refreshIcon.classList.add('animate-spin'); if (refreshIcon) refreshIcon.classList.add('animate-spin');
let d; let d;
try { try {
@ -159,14 +167,14 @@
const pub = d.publish || {}, pbs = pub.byStatus || {}; const pub = d.publish || {}, pbs = pub.byStatus || {};
// KPI (count-up for numbers, textContent for captions) // KPI (count-up for numbers, textContent for captions)
countUp(document.getElementById('kTotal'), total); setKpi('kTotal', total);
document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH); document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH);
countUp(document.getElementById('kNew'), bs.NEW); setKpi('kNew', bs.NEW);
document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING); document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING);
countUp(document.getElementById('kTarget'), bs.TARGET); setKpi('kTarget', bs.TARGET);
countUp(document.getElementById('kDone'), bs.DONE); setKpi('kDone', bs.DONE);
document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED); document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED);
countUp(document.getElementById('kPublished'), pbs.PUBLISHED); setKpi('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 // 깔때기 (총 수집 대비) — 3-tone color scheme
@ -192,7 +200,7 @@
'<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 class="badge '+ratioBadgeClass(v.viewsPerSubRatio)+'" style="flex-shrink:0;">'+ratio+'</span></a>'; '<span class="badge '+(v.viewsPerSubRatio != null ? ratioBadgeClass(v.viewsPerSubRatio) : 'badge-muted')+'" style="flex-shrink:0;">'+ratio+'</span></a>';
}).join(''); }).join('');
} }
@ -224,7 +232,7 @@
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 = catList.innerHTML =
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('') + cats.map(c => '<a href="/collection?categoryId='+encodeURIComponent(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>'; '<a href="/collection" style="text-decoration:none; display:block;">'+bar('미분류', uncat, total, '#475569')+'</a>';
} }
@ -239,6 +247,7 @@
'<a href="/collection" style="text-decoration:none; display:block;">'+bar('롱폼', longForm, total, 'var(--success)')+'</a>'; '<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();
dashboardAnimated = true;
} }
loadDashboard(); loadDashboard();