Compare commits

..

2 Commits

Author SHA1 Message Date
97192d7372 chore: 원격 푸시 동작 테스트
Gitea 원격(origin) 푸시가 정상 동작하는지 확인하는 테스트 커밋.
docs/push-test.md 추가 — 확인 후 삭제 가능.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:01 +09:00
22dd2f6d87 feat(ui): action-first editorial dashboard (frontend-design)
Rebuild the dashboard with a distinctive editorial identity (Bricolage Grotesque
display + JetBrains Mono tickers + paper canvas + vermilion signal accent, ruled
layout, grain, staggered load) scoped to .fd-dash so other pages keep the SaaS
theme. UX is action-first:
- action bar: 미검토/떡상 후보/재가공 대기 with primary CTAs (real counts)
- 떡상 후보 leaderboard wired to real /api/dashboard/summary outperformers, with
  working per-row 재가공(/rework/{id}) and 제외(status EXCLUDED) actions
- pipeline funnel with bottleneck callout + 칸반 CTA
- source/format + publish with empty-state guidance
Light/dark aware. Editorial fonts loaded in base.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:01 +09:00
3 changed files with 227 additions and 212 deletions

6
docs/push-test.md Normal file
View File

@ -0,0 +1,6 @@
# 푸시 테스트
Gitea 원격(`origin`, h-git.tolag.shop/hehihoho3/h-lab) 푸시가 정상 동작하는지 확인용 파일입니다.
확인 후 삭제해도 됩니다.
- 작성: 2026-06-16

View File

@ -3,251 +3,258 @@
layout:decorate="~{layout/base}">
<head>
<title>h-lab - Dashboard</title>
<title>h-lab - 대시보드</title>
</head>
<body>
<div layout:fragment="content">
<header class="mb-4">
<div class="flex items-center justify-between">
<div class="fd-dash">
<div class="fd-masthead">
<div>
<h1 class="text-xl font-bold mb-1">대시보드</h1>
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행</div>
<h1>대시보드</h1>
</div>
<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> 새로고침
<div class="fd-meta">
<div class="fd-tick">PIPELINE STATUS</div>
<div class="fd-big" id="fdDate"></div>
<button class="fd-btn fd-sm" onclick="loadDashboard()" style="margin-top:8px;">
<i data-lucide="refresh-cw" style="width:13px;"></i> 새로고침
</button>
</div>
</header>
<!-- KPI 카드 -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
<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><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, rgba(255,255,255,0.06));"><i data-lucide="library" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div>
</a>
<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><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, rgba(255,255,255,0.06));"><i data-lucide="inbox" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
</a>
<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><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, rgba(255,255,255,0.06));"><i data-lucide="clapperboard" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted">재가공 대상</div>
</a>
<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><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, rgba(255,255,255,0.06));"><i data-lucide="check-circle" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="kExcludedCap">제외 -</div>
</a>
<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><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, rgba(255,255,255,0.06));"><i data-lucide="send" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="kPublishCap">대기 - · 작성중 -</div>
</a>
</div>
<!-- 파이프라인 깔때기 -->
<div class="card mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">파이프라인 현황</h3>
<span class="text-sm text-muted" id="funnelExcluded"></span>
<div class="fd-body fd-stagger">
<!-- ACTION BAR -->
<section class="fd-actions" style="animation-delay:.02s">
<a class="fd-act fd-hot" th:href="@{/board}">
<div class="fd-act-top">
<div class="fd-num" id="aNew"></div>
<div class="fd-act-lab"><div class="t">미검토 영상</div><div class="s" id="aNewSub">NEW · 검토 대기</div></div>
</div>
<div id="funnel" class="flex flex-col gap-3">
<div class="skeleton" style="height:48px;"></div>
<div class="fd-cta-row"><span class="fd-btn fd-sig">검토 시작 →</span></div>
</a>
<a class="fd-act" th:href="@{/discover}">
<div class="fd-act-top">
<div class="fd-num" id="aOut" style="color:var(--fd-signal)"></div>
<div class="fd-act-lab"><div class="t">떡상 후보</div><div class="s" id="aOutSub">배율 상위</div></div>
</div>
<div class="fd-cta-row"><span class="fd-btn">발굴 열기 →</span></div>
</a>
<a class="fd-act" th:href="@{/collection(status='TARGET')}">
<div class="fd-act-top">
<div class="fd-num" id="aTarget" style="color:var(--fd-ink-3)"></div>
<div class="fd-act-lab"><div class="t">재가공 대기</div><div class="s">TARGET</div></div>
</div>
<div class="fd-muted-cta" id="aTargetCta">발굴에서 후보를 TARGET으로 보내세요 →</div>
</a>
</section>
<!-- WORK SURFACE -->
<section class="fd-cols" style="animation-delay:.1s">
<div class="fd-panel">
<div class="fd-ph"><h3>떡상 후보 — 바로 처리</h3><a class="fd-tick" style="color:var(--fd-signal)" th:href="@{/discover}">발굴 전체 →</a></div>
<div class="fd-pb" id="leadList"><div class="fd-loading">불러오는 중…</div></div>
</div>
<div class="fd-panel">
<div class="fd-ph"><h3>파이프라인</h3><span class="fd-tick" id="pipeTotal">총 —</span></div>
<div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div>
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">출처 · 포맷</h3><a class="fd-tick" th:href="@{/collection}">수집함 →</a></div>
<div class="fd-pb" id="srcFmt"></div>
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">발행 현황</h3><a class="fd-tick" th:href="@{/publish}">발행 큐 →</a></div>
<div class="fd-pb" id="pubBox"></div>
</div>
</section>
</div>
</div>
<!-- 떡상 / 발행 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; margin-bottom: 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="@{/discover}" class="text-sm text-muted hover:text-white">발굴 →</a>
</div>
<div class="flex flex-col gap-2" id="opList"><div class="skeleton" style="height:48px;"></div></div>
</div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">발행 현황</h3>
<a th:href="@{/publish}" class="text-sm text-muted hover:text-white">발행 큐 →</a>
</div>
<div id="publishBars" class="flex flex-col gap-2 mb-3"></div>
<div id="publishRecent" class="flex flex-col gap-1"></div>
</div>
</div>
<style>
/* ===== Editorial identity — scoped to .fd-dash ===== */
.fd-dash{
--fd-paper:#f1ede3; --fd-panel:#fbfaf6; --fd-ink:#17160f; --fd-ink-2:#6f6a5c; --fd-ink-3:#a8a293;
--fd-rule:#ddd7c8; --fd-rule-strong:#17160f; --fd-signal:#ff4d23; --fd-signal-soft:#ffe6df; --fd-lime:#1f7a3a;
--fd-disp:'Bricolage Grotesque', sans-serif; --fd-body:'Hanken Grotesk', sans-serif; --fd-mono:'JetBrains Mono', monospace;
font-family:var(--fd-body); color:var(--fd-ink);
margin:-2.5rem; padding:0; min-height:100vh; background:var(--fd-paper); position:relative;
}
:root[data-theme="dark"] .fd-dash{
--fd-paper:#100f0c; --fd-panel:#191712; --fd-ink:#eceae2; --fd-ink-2:#a39d8d; --fd-ink-3:#6b6657;
--fd-rule:#2b281f; --fd-rule-strong:#3a362a;
}
.fd-dash::before{ content:""; position:absolute; inset:0; pointer-events:none; opacity:.05;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.fd-dash a{ text-decoration:none; color:inherit; }
.fd-tick{ font-family:var(--fd-mono); font-size:10.5px; font-weight:500; letter-spacing:.18em; text-transform:uppercase; color:var(--fd-ink-3); }
<!-- 카테고리 / 출처·포맷 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="card">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">카테고리 분포</h3>
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
</div>
<div id="catList" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
</div>
<div class="card">
<h3 class="text-lg font-bold mb-4">수집 출처 &amp; 포맷</h3>
<div id="sourceFormat" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
</div>
</div>
.fd-masthead{ display:flex; justify-content:space-between; align-items:flex-end; padding:24px 34px 16px; border-bottom:1.5px solid var(--fd-rule-strong); position:relative; }
.fd-masthead h1{ font-family:var(--fd-disp); font-weight:800; font-size:32px; letter-spacing:-.025em; line-height:.95; }
.fd-meta{ text-align:right; }
.fd-meta .fd-big{ font-family:var(--fd-mono); font-weight:700; font-size:13px; }
.fd-body{ padding:24px 34px 40px; position:relative; }
.fd-stagger > *{ opacity:0; transform:translateY(10px); animation:fdRise .7s cubic-bezier(.2,.7,.2,1) forwards; }
@keyframes fdRise{ to{ opacity:1; transform:none; } }
@media (prefers-reduced-motion: reduce){ .fd-stagger > *{ animation:none; opacity:1; transform:none; } }
.fd-btn{ display:inline-flex; align-items:center; gap:7px; font-family:var(--fd-mono); font-weight:700; font-size:11.5px; letter-spacing:.04em; padding:8px 13px; border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); color:var(--fd-ink); cursor:pointer; }
.fd-btn:hover{ background:var(--fd-ink); color:var(--fd-paper); }
.fd-btn.fd-sig{ background:var(--fd-signal); border-color:var(--fd-signal); color:#fff; }
.fd-btn.fd-sig:hover{ background:#e23c14; color:#fff; }
.fd-btn.fd-sm{ padding:5px 9px; font-size:10.5px; }
.fd-actions{ display:grid; grid-template-columns:1.3fr 1fr 1fr; border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); margin-bottom:22px; }
.fd-act{ padding:20px 22px; border-right:1.5px solid var(--fd-rule-strong); display:flex; flex-direction:column; gap:12px; position:relative; overflow:hidden; }
.fd-act:last-child{ border-right:none; }
.fd-act:hover{ background:#f3eee2; }
:root[data-theme="dark"] .fd-act:hover{ background:#211e17; }
.fd-act-top{ display:flex; align-items:baseline; gap:12px; }
.fd-num{ font-family:var(--fd-disp); font-weight:800; font-size:60px; line-height:.8; letter-spacing:-.04em; font-variant-numeric:tabular-nums; }
.fd-act-lab .t{ font-weight:700; font-size:14px; }
.fd-act-lab .s{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-3); margin-top:3px; }
.fd-hot::after{ content:""; position:absolute; right:-60px; top:-60px; width:200px; height:200px; border-radius:50%; background:radial-gradient(circle, rgba(255,77,35,.14), transparent 62%); }
.fd-cta-row{ display:flex; gap:8px; z-index:1; }
.fd-muted-cta{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-3); line-height:1.5; }
.fd-cols{ display:grid; grid-template-columns:1.55fr 1fr; gap:22px; align-items:start; }
.fd-panel{ border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); }
.fd-ph{ display:flex; justify-content:space-between; align-items:center; padding:13px 20px; border-bottom:1.5px solid var(--fd-rule-strong); }
.fd-ph h3{ font-family:var(--fd-disp); font-weight:700; font-size:15px; letter-spacing:-.01em; }
.fd-pb{ padding:4px 20px 12px; }
.fd-loading{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-3); padding:20px 0; text-align:center; }
.fd-lead{ display:grid; grid-template-columns:30px 52px 1fr auto auto; gap:13px; align-items:center; padding:11px 0; border-bottom:1px solid var(--fd-rule); }
.fd-lead:last-child{ border-bottom:none; }
.fd-lead:hover{ background:#f3eee2; }
:root[data-theme="dark"] .fd-lead:hover{ background:#211e17; }
.fd-rk{ font-family:var(--fd-disp); font-weight:800; font-size:23px; line-height:1; font-variant-numeric:tabular-nums; }
.fd-lead:first-child .fd-rk{ color:var(--fd-signal); }
.fd-lead .fd-th{ width:52px; height:30px; object-fit:cover; background:#e7e1d2; border:1px solid var(--fd-rule); }
.fd-lead .fd-t{ font-weight:600; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:230px; }
.fd-lead .fd-s{ font-family:var(--fd-mono); font-size:10px; color:var(--fd-ink-3); margin-top:2px; }
.fd-ratio{ font-family:var(--fd-disp); font-weight:800; font-size:17px; color:var(--fd-signal); font-variant-numeric:tabular-nums; }
.fd-actcell{ display:flex; gap:6px; opacity:.4; transition:opacity .15s; }
.fd-lead:hover .fd-actcell{ opacity:1; }
.fd-fbar{ margin-bottom:11px; }
.fd-fbar .top{ display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px; }
.fd-fbar .top .k{ font-size:12px; font-weight:600; }
.fd-fbar .top .v{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-2); }
.fd-fbar .track{ height:8px; background:var(--fd-rule); border:1px solid var(--fd-rule); position:relative; }
.fd-fbar .track > i{ position:absolute; left:0; top:0; bottom:0; background:var(--fd-ink); }
.fd-fbar.fd-sigbar .track > i{ background:var(--fd-signal); }
.fd-bottleneck{ display:flex; justify-content:space-between; align-items:center; margin-top:12px; padding-top:12px; border-top:1px dashed var(--fd-rule-strong); }
.fd-bottleneck .msg{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-2); }
.fd-bottleneck .msg b{ color:var(--fd-signal); }
.fd-row{ display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--fd-rule); font-size:12.5px; }
.fd-row:last-child{ border-bottom:none; }
.fd-row .v{ font-family:var(--fd-mono); color:var(--fd-ink-2); }
.fd-row .v b{ color:var(--fd-ink); }
.fd-empty{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-3); padding:14px 0; }
@media (max-width:1100px){ .fd-cols{ grid-template-columns:1fr; } .fd-actions{ grid-template-columns:1fr; } .fd-act{ border-right:none; border-bottom:1.5px solid var(--fd-rule-strong); } }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
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 pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; }
function fmt(n){ return (n==null)?'0':Number(n).toLocaleString(); }
function pct(n,t){ return t>0?Math.round((Number(n||0)/t)*100):0; }
let dashboardAnimated = false;
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);
}
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){
const r = await fetch(url);
async function getData(url, opts){
const r = await fetch(url, opts);
const j = await r.json().catch(()=>({}));
if(!r.ok || (j && j.success===false)) throw new Error((j && j.message) || ('HTTP '+r.status));
if(!r.ok || (j && j.success===false)) throw new Error((j&&j.message)||('HTTP '+r.status));
return j.data;
}
function bar(label, count, total, color){
const p = pct(count, total);
return `<div class="flex items-center gap-3">
<span class="text-sm" style="width:92px; flex-shrink:0;">${esc(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${p}%; background:${color};"></div></div>
<span class="text-sm font-bold" style="width:70px; text-align:right; flex-shrink:0;">${fmt(count)} <span class="text-muted" style="font-weight:normal;">(${p}%)</span></span>
</div>`;
}
(function(){ const d=new Date(); const p=n=>String(n).padStart(2,'0');
document.getElementById('fdDate').textContent = d.getFullYear()+' · '+p(d.getMonth()+1)+' · '+p(d.getDate()); })();
function ratioBadgeClass(r){ const v=Number(r); return v>=10?'badge-danger':(v>=2?'badge-warning':'badge-success'); }
function fbar(label, count, total, signal){
const p = pct(count,total);
return `<div class="fd-fbar ${signal?'fd-sigbar':''}"><div class="top"><span class="k">${esc(label)}</span><span class="v">${fmt(count)} · ${p}%</span></div><div class="track"><i style="width:${p}%"></i></div></div>`;
}
async function loadDashboard(){
const refreshIcon = document.querySelector('#refreshBtn svg, #refreshBtn i');
if (refreshIcon) refreshIcon.classList.add('animate-spin');
let d;
try {
d = await getData('/api/dashboard/summary');
} catch(e) {
document.getElementById('funnel').innerHTML = '<p class="text-danger text-sm">불러오기 실패: '+esc(e.message)+'</p>';
return;
} finally {
if (refreshIcon) refreshIcon.classList.remove('animate-spin');
try { d = await getData('/api/dashboard/summary'); }
catch(e){ document.getElementById('leadList').innerHTML = '<div class="fd-empty" style="color:var(--fd-signal)">불러오기 실패: '+esc(e.message)+'</div>'; return; }
const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{};
const total = Number(pipe.total||0);
const pub = d.publish||{}, pbs = pub.byStatus||{};
const op = d.outperformers||[];
// ----- 액션 바 -----
document.getElementById('aNew').textContent = fmt(bs.NEW);
document.getElementById('aNewSub').textContent = 'NEW · 검토 대기' + (bs.REVIEWING?(' · 검토중 '+fmt(bs.REVIEWING)):'');
document.getElementById('aOut').textContent = op.length ? fmt(op.length) : '0';
document.getElementById('aOutSub').textContent = op.length ? ('배율 상위 · 최고 '+(op[0].viewsPerSubRatio!=null?Number(op[0].viewsPerSubRatio).toFixed(0)+'×':'-')) : '아직 없음';
document.getElementById('aTarget').textContent = fmt(bs.TARGET);
document.getElementById('aTargetCta').textContent = Number(bs.TARGET||0)>0 ? '재가공 작업공간에서 이어서 →' : '발굴에서 후보를 TARGET으로 보내세요 →';
// ----- 떡상 리더보드 (실데이터 + 액션) -----
const lead = document.getElementById('leadList');
if(op.length===0){ lead.innerHTML = '<div class="fd-empty">아직 떡상 후보가 없습니다. 채널을 수집하면 배율 상위 영상이 여기 모입니다.</div>'; }
else {
lead.innerHTML = op.slice(0,6).map((v,i)=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(0)+'×' : '';
return `<div class="fd-lead" data-id="${v.id}">
<div class="fd-rk">${i+1}</div>
<img class="fd-th" src="${esc(v.thumbnailUrl)}" alt="">
<div style="min-width:0;">
<div class="fd-t"><a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank">${esc(v.title)}</a></div>
<div class="fd-s">SHORTS · ${esc(v.channelTitle||'')} · ${fmt(v.viewCount)}</div>
</div>
<div class="fd-ratio">${ratio}</div>
<div class="fd-actcell">
<a class="fd-btn fd-sm fd-sig" href="/rework/${v.id}">재가공 →</a>
<button class="fd-btn fd-sm" onclick="excludeVideo(${v.id})">제외</button>
</div>
</div>`;
}).join('');
}
const pipe = d.pipeline || {};
const bs = pipe.byStatus || {}, src = pipe.bySource || {};
const total = Number(pipe.total || 0);
const pub = d.publish || {}, pbs = pub.byStatus || {};
// KPI (count-up for numbers, textContent for captions)
setKpi('kTotal', total);
document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH);
setKpi('kNew', bs.NEW);
document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING);
setKpi('kTarget', bs.TARGET);
setKpi('kDone', bs.DONE);
document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED);
setKpi('kPublished', pbs.PUBLISHED);
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('pipeTotal').textContent = '총 ' + fmt(total);
const stages = [['미검토',bs.NEW],['검토중',bs.REVIEWING],['작업대상',bs.TARGET],['완료',bs.DONE],['발행완료',pbs.PUBLISHED]];
let bottleneck = stages[0];
for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; }
document.getElementById('funnel').innerHTML =
bar('미검토', bs.NEW, total, '#64748b') +
bar('검토중', bs.REVIEWING, total, 'var(--primary)') +
bar('작업대상', bs.TARGET, total, 'var(--warning)') +
bar('완료', bs.DONE, total, 'var(--success)') +
bar('발행완료', pbs.PUBLISHED, total, 'var(--success)');
fbar('미검토', bs.NEW, total, true) +
fbar('검토중', bs.REVIEWING, total) +
fbar('작업대상', bs.TARGET, total) +
fbar('완료', bs.DONE, total) +
fbar('발행완료', pbs.PUBLISHED, total) +
`<div class="fd-bottleneck"><div class="msg">병목: <b>${esc(bottleneck[0])} ${fmt(bottleneck[1])}</b> — 다음 단계로 옮겨보세요</div><a class="fd-btn fd-sm fd-sig" href="/board">칸반 보드</a></div>`;
// 떡상 TOP — rank badge + ratio badge
const op = d.outperformers || [];
const opList = document.getElementById('opList');
if(op.length===0){ opList.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; }
else {
opList.innerHTML = op.map((v,i)=>{
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:var(--surface-2); text-decoration:none;">' +
'<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;">' +
'<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>' +
'<span class="badge '+(v.viewsPerSubRatio != null ? ratioBadgeClass(v.viewsPerSubRatio) : 'badge-muted')+'" style="flex-shrink:0;">'+ratio+'</span></a>';
}).join('');
}
// ----- 출처/포맷 -----
document.getElementById('srcFmt').innerHTML =
`<div class="fd-row"><span>채널 수집</span><span class="v"><b>${fmt(src.CHANNEL)}</b> · ${pct(src.CHANNEL,total)}%</span></div>` +
`<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` +
`<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`;
// 발행 현황 — badge for status
const ptotal = Number(pub.total || 0);
document.getElementById('publishBars').innerHTML =
bar('작성중', pbs.DRAFT, ptotal, '#64748b') +
bar('발행대기', pbs.READY, ptotal, 'var(--primary)') +
bar('발행완료', pbs.PUBLISHED, ptotal, 'var(--success)');
const recent = pub.recent || [];
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
? '<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=>{
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:var(--surface-2); text-decoration:none;">' +
'<span class="text-sm truncate" style="max-width:170px; color:var(--text);">'+esc(p.title||'(제목 없음)')+'</span>' +
'<span class="badge '+st.cls+'" style="flex-shrink:0;">'+st.t+'</span></a>';
}).join('');
// 카테고리 분포 — clickable deep links
const cats = (d.categories && d.categories.categories) || [];
const uncat = (d.categories && d.categories.uncategorized) || 0;
const catList = document.getElementById('catList');
if(cats.length===0 && !uncat){ catList.innerHTML = '<p class="text-muted text-sm">분류된 영상이 없습니다.</p>'; }
else {
catList.innerHTML =
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>';
}
// 출처 & 포맷 — clickable deep links + 3-tone colors
const shorts = Number(pipe.shorts || 0), longForm = Number(pipe.longForm || 0);
document.getElementById('sourceFormat').innerHTML =
'<div class="text-sm text-muted" style="margin-bottom:2px;">출처</div>' +
'<a href="/collection?source=CHANNEL" style="text-decoration:none; display:block;">'+bar('채널 수집', src.CHANNEL, total, 'var(--primary)')+'</a>' +
'<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>' +
'<a href="/collection?shortsOnly=true" style="text-decoration:none; display:block;">'+bar('Shorts', shorts, total, 'var(--danger)')+'</a>' +
'<a href="/collection" style="text-decoration:none; display:block;">'+bar('롱폼', longForm, total, 'var(--success)')+'</a>';
// ----- 발행 -----
const ptotal = Number(pub.total||0);
document.getElementById('pubBox').innerHTML = ptotal===0
? '<div class="fd-empty">발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.</div>'
: `<div class="fd-row"><span>작성중</span><span class="v"><b>${fmt(pbs.DRAFT)}</b></span></div>`+
`<div class="fd-row"><span>발행대기</span><span class="v"><b>${fmt(pbs.READY)}</b></span></div>`+
`<div class="fd-row"><span>발행완료</span><span class="v"><b>${fmt(pbs.PUBLISHED)}</b></span></div>`;
if(window.lucide) lucide.createIcons();
dashboardAnimated = true;
}
async function excludeVideo(id){
if(!confirm('이 영상을 제외(EXCLUDED) 처리할까요? 떡상 후보에서 사라집니다.')) return;
try {
await getData('/api/v1/channel-videos/'+id+'/status', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({status:'EXCLUDED'}) });
loadDashboard();
} catch(e){ alert('제외 실패: '+e.message); }
}
loadDashboard();

View File

@ -18,6 +18,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Editorial identity (dashboard) -->
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<!-- CSS -->
<link rel="stylesheet" th:href="@{/css/variables.css}">