Compare commits

..

No commits in common. "97192d7372b756fe7091fed15440ece22df092d5" and "4508c3ef18a34a5cdaee94427370058c13752eb2" have entirely different histories.

3 changed files with 208 additions and 223 deletions

View File

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

View File

@ -3,258 +3,251 @@
layout:decorate="~{layout/base}"> layout:decorate="~{layout/base}">
<head> <head>
<title>h-lab - 대시보드</title> <title>h-lab - Dashboard</title>
</head> </head>
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="fd-dash"> <header class="mb-4">
<div class="flex items-center justify-between">
<div class="fd-masthead">
<div> <div>
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행</div> <h1 class="text-xl font-bold mb-1">대시보드</h1>
<h1>대시보드</h1> <p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
</div>
<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> </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> 새로고침
</button>
</div> </div>
</header>
<div class="fd-body fd-stagger"> <!-- KPI 카드 -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
<!-- ACTION BAR --> <a th:href="@{/collection}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<section class="fd-actions" style="animation-delay:.02s"> <div class="flex justify-between items-start mb-3">
<a class="fd-act fd-hot" th:href="@{/board}"> <div><p class="text-sm text-muted">수집 영상</p><h3 class="text-2xl font-bold" id="kTotal">-</h3></div>
<div class="fd-act-top"> <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 class="fd-num" id="aNew"></div> </div>
<div class="fd-act-lab"><div class="t">미검토 영상</div><div class="s" id="aNewSub">NEW · 검토 대기</div></div> <div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div>
</div> </a>
<div class="fd-cta-row"><span class="fd-btn fd-sig">검토 시작 →</span></div> <a th:href="@{/collection(status='NEW')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
</a> <div class="flex justify-between items-start mb-3">
<a class="fd-act" th:href="@{/discover}"> <div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-2xl font-bold" id="kNew">-</h3></div>
<div class="fd-act-top"> <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 class="fd-num" id="aOut" style="color:var(--fd-signal)"></div> </div>
<div class="fd-act-lab"><div class="t">떡상 후보</div><div class="s" id="aOutSub">배율 상위</div></div> <div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
</div> </a>
<div class="fd-cta-row"><span class="fd-btn">발굴 열기 →</span></div> <a th:href="@{/collection(status='TARGET')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
</a> <div class="flex justify-between items-start mb-3">
<a class="fd-act" th:href="@{/collection(status='TARGET')}"> <div><p class="text-sm text-muted">작업대상 (TARGET)</p><h3 class="text-2xl font-bold" id="kTarget">-</h3></div>
<div class="fd-act-top"> <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 class="fd-num" id="aTarget" style="color:var(--fd-ink-3)"></div> </div>
<div class="fd-act-lab"><div class="t">재가공 대기</div><div class="s">TARGET</div></div> <div class="text-sm text-muted">재가공 대상</div>
</div> </a>
<div class="fd-muted-cta" id="aTargetCta">발굴에서 후보를 TARGET으로 보내세요 →</div> <a th:href="@{/collection(status='DONE')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
</a> <div class="flex justify-between items-start mb-3">
</section> <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>
<!-- WORK SURFACE --> </div>
<section class="fd-cols" style="animation-delay:.1s"> <div class="text-sm text-muted" id="kExcludedCap">제외 -</div>
<div class="fd-panel"> </a>
<div class="fd-ph"><h3>떡상 후보 — 바로 처리</h3><a class="fd-tick" style="color:var(--fd-signal)" th:href="@{/discover}">발굴 전체 →</a></div> <a th:href="@{/publish}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
<div class="fd-pb" id="leadList"><div class="fd-loading">불러오는 중…</div></div> <div class="flex justify-between items-start mb-3">
</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, rgba(255,255,255,0.06));"><i data-lucide="send" color="var(--primary)"></i></div>
<div class="fd-panel"> </div>
<div class="fd-ph"><h3>파이프라인</h3><span class="fd-tick" id="pipeTotal">총 —</span></div> <div class="text-sm text-muted" id="kPublishCap">대기 - · 작성중 -</div>
<div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div> </a>
<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>
<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 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>
<div id="funnel" class="flex flex-col gap-3">
<div class="skeleton" style="height:48px;"></div>
</div> </div>
</div> </div>
<style> <!-- 떡상 / 발행 -->
/* ===== Editorial identity — scoped to .fd-dash ===== */ <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
.fd-dash{ <div class="card">
--fd-paper:#f1ede3; --fd-panel:#fbfaf6; --fd-ink:#17160f; --fd-ink-2:#6f6a5c; --fd-ink-3:#a8a293; <div class="flex items-center justify-between mb-4">
--fd-rule:#ddd7c8; --fd-rule-strong:#17160f; --fd-signal:#ff4d23; --fd-signal-soft:#ffe6df; --fd-lime:#1f7a3a; <h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
--fd-disp:'Bricolage Grotesque', sans-serif; --fd-body:'Hanken Grotesk', sans-serif; --fd-mono:'JetBrains Mono', monospace; <a th:href="@{/discover}" class="text-sm text-muted hover:text-white">발굴 →</a>
font-family:var(--fd-body); color:var(--fd-ink); </div>
margin:-2.5rem; padding:0; min-height:100vh; background:var(--fd-paper); position:relative; <div class="flex flex-col gap-2" id="opList"><div class="skeleton" style="height:48px;"></div></div>
} </div>
:root[data-theme="dark"] .fd-dash{ <div class="card">
--fd-paper:#100f0c; --fd-panel:#191712; --fd-ink:#eceae2; --fd-ink-2:#a39d8d; --fd-ink-3:#6b6657; <div class="flex items-center justify-between mb-4">
--fd-rule:#2b281f; --fd-rule-strong:#3a362a; <h3 class="text-lg font-bold">발행 현황</h3>
} <a th:href="@{/publish}" class="text-sm text-muted hover:text-white">발행 큐 →</a>
.fd-dash::before{ content:""; position:absolute; inset:0; pointer-events:none; opacity:.05; </div>
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"); } <div id="publishBars" class="flex flex-col gap-2 mb-3"></div>
.fd-dash a{ text-decoration:none; color:inherit; } <div id="publishRecent" class="flex flex-col gap-1"></div>
.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>
</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; } <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
.fd-meta{ text-align:right; } <div class="card">
.fd-meta .fd-big{ font-family:var(--fd-mono); font-weight:700; font-size:13px; } <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">카테고리 분포</h3>
.fd-body{ padding:24px 34px 40px; position:relative; } <a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
.fd-stagger > *{ opacity:0; transform:translateY(10px); animation:fdRise .7s cubic-bezier(.2,.7,.2,1) forwards; } </div>
@keyframes fdRise{ to{ opacity:1; transform:none; } } <div id="catList" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
@media (prefers-reduced-motion: reduce){ .fd-stagger > *{ animation:none; opacity:1; transform:none; } } </div>
<div class="card">
.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; } <h3 class="text-lg font-bold mb-4">수집 출처 &amp; 포맷</h3>
.fd-btn:hover{ background:var(--fd-ink); color:var(--fd-paper); } <div id="sourceFormat" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
.fd-btn.fd-sig{ background:var(--fd-signal); border-color:var(--fd-signal); color:#fff; } </div>
.fd-btn.fd-sig:hover{ background:#e23c14; color:#fff; } </div>
.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"> <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,t){ return t>0?Math.round((Number(n||0)/t)*100):0; } function pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; }
async function getData(url, opts){ let dashboardAnimated = false;
const r = await fetch(url, opts);
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);
const j = await r.json().catch(()=>({})); 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; return j.data;
} }
(function(){ const d=new Date(); const p=n=>String(n).padStart(2,'0'); function bar(label, count, total, color){
document.getElementById('fdDate').textContent = d.getFullYear()+' · '+p(d.getMonth()+1)+' · '+p(d.getDate()); })(); const p = pct(count, total);
return `<div class="flex items-center gap-3">
function fbar(label, count, total, signal){ <span class="text-sm" style="width:92px; flex-shrink:0;">${esc(label)}</span>
const p = pct(count,total); <div class="bar-track"><div class="bar-fill" style="width:${p}%; background:${color};"></div></div>
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>`; <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 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('#refreshBtn svg, #refreshBtn i');
if (refreshIcon) refreshIcon.classList.add('animate-spin');
let d; let d;
try { d = await getData('/api/dashboard/summary'); } try {
catch(e){ document.getElementById('leadList').innerHTML = '<div class="fd-empty" style="color:var(--fd-signal)">불러오기 실패: '+esc(e.message)+'</div>'; return; } 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');
}
const pipe = d.pipeline || {};
const bs = pipe.byStatus || {}, src = pipe.bySource || {};
const total = Number(pipe.total || 0);
const pub = d.publish || {}, pbs = pub.byStatus || {};
const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{}; // KPI (count-up for numbers, textContent for captions)
const total = Number(pipe.total||0); setKpi('kTotal', total);
const pub = d.publish||{}, pbs = pub.byStatus||{}; document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH);
const op = d.outperformers||[]; 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('aNew').textContent = fmt(bs.NEW); document.getElementById('funnelExcluded').textContent = '총 ' + fmt(total) + '건 · 제외 ' + fmt(bs.EXCLUDED);
document.getElementById('aNewSub').textContent = 'NEW · 검토 대기' + (bs.REVIEWING?(' · 검토중 '+fmt(bs.REVIEWING)):''); document.getElementById('funnel').innerHTML =
document.getElementById('aOut').textContent = op.length ? fmt(op.length) : '0'; bar('미검토', bs.NEW, total, '#64748b') +
document.getElementById('aOutSub').textContent = op.length ? ('배율 상위 · 최고 '+(op[0].viewsPerSubRatio!=null?Number(op[0].viewsPerSubRatio).toFixed(0)+'×':'-')) : '아직 없음'; bar('검토중', bs.REVIEWING, total, 'var(--primary)') +
document.getElementById('aTarget').textContent = fmt(bs.TARGET); bar('작업대상', bs.TARGET, total, 'var(--warning)') +
document.getElementById('aTargetCta').textContent = Number(bs.TARGET||0)>0 ? '재가공 작업공간에서 이어서 →' : '발굴에서 후보를 TARGET으로 보내세요 →'; bar('완료', bs.DONE, total, 'var(--success)') +
bar('발행완료', pbs.PUBLISHED, total, 'var(--success)');
// ----- 떡상 리더보드 (실데이터 + 액션) ----- // 떡상 TOP — rank badge + ratio badge
const lead = document.getElementById('leadList'); const op = d.outperformers || [];
if(op.length===0){ lead.innerHTML = '<div class="fd-empty">아직 떡상 후보가 없습니다. 채널을 수집하면 배율 상위 영상이 여기 모입니다.</div>'; } const opList = document.getElementById('opList');
if(op.length===0){ opList.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; }
else { else {
lead.innerHTML = op.slice(0,6).map((v,i)=>{ opList.innerHTML = op.map((v,i)=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(0)+'×' : ''; const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-';
return `<div class="fd-lead" data-id="${v.id}"> 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="fd-rk">${i+1}</div> '<div class="flex items-center gap-2" style="min-width:0;">' +
<img class="fd-th" src="${esc(v.thumbnailUrl)}" alt=""> '<span class="badge badge-muted" style="flex-shrink:0;">'+(i+1)+'</span>' +
<div style="min-width:0;"> '<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink:0;">' +
<div class="fd-t"><a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank">${esc(v.title)}</a></div> '<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:300px;">'+esc(v.title)+'</div>' +
<div class="fd-s">SHORTS · ${esc(v.channelTitle||'')} · ${fmt(v.viewCount)}</div> '<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+fmt(v.viewCount)+'</div></div>' +
</div> '</div>' +
<div class="fd-ratio">${ratio}</div> '<span class="badge '+(v.viewsPerSubRatio != null ? ratioBadgeClass(v.viewsPerSubRatio) : 'badge-muted')+'" style="flex-shrink:0;">'+ratio+'</span></a>';
<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(''); }).join('');
} }
// ----- 파이프라인 + 병목 ----- // 발행 현황 — badge for status
document.getElementById('pipeTotal').textContent = '총 ' + fmt(total); const ptotal = Number(pub.total || 0);
const stages = [['미검토',bs.NEW],['검토중',bs.REVIEWING],['작업대상',bs.TARGET],['완료',bs.DONE],['발행완료',pbs.PUBLISHED]]; document.getElementById('publishBars').innerHTML =
let bottleneck = stages[0]; bar('작성중', pbs.DRAFT, ptotal, '#64748b') +
for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; } bar('발행대기', pbs.READY, ptotal, 'var(--primary)') +
document.getElementById('funnel').innerHTML = bar('발행완료', pbs.PUBLISHED, ptotal, 'var(--success)');
fbar('미검토', bs.NEW, total, true) + const recent = pub.recent || [];
fbar('검토중', bs.REVIEWING, total) + const PUB_ST = {
fbar('작업대상', bs.TARGET, total) + DRAFT: { t:'작성중', cls:'badge-muted' },
fbar('완료', bs.DONE, total) + READY: { t:'대기', cls:'badge-warning' },
fbar('발행완료', pbs.PUBLISHED, total) + PUBLISHED: { t:'완료', cls:'badge-success' }
`<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>`; };
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
document.getElementById('srcFmt').innerHTML = const cats = (d.categories && d.categories.categories) || [];
`<div class="fd-row"><span>채널 수집</span><span class="v"><b>${fmt(src.CHANNEL)}</b> · ${pct(src.CHANNEL,total)}%</span></div>` + const uncat = (d.categories && d.categories.uncategorized) || 0;
`<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` + const catList = document.getElementById('catList');
`<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`; 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 ptotal = Number(pub.total||0); const shorts = Number(pipe.shorts || 0), longForm = Number(pipe.longForm || 0);
document.getElementById('pubBox').innerHTML = ptotal===0 document.getElementById('sourceFormat').innerHTML =
? '<div class="fd-empty">발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.</div>' '<div class="text-sm text-muted" style="margin-bottom:2px;">출처</div>' +
: `<div class="fd-row"><span>작성중</span><span class="v"><b>${fmt(pbs.DRAFT)}</b></span></div>`+ '<a href="/collection?source=CHANNEL" style="text-decoration:none; display:block;">'+bar('채널 수집', src.CHANNEL, total, 'var(--primary)')+'</a>' +
`<div class="fd-row"><span>발행대기</span><span class="v"><b>${fmt(pbs.READY)}</b></span></div>`+ '<a href="/collection?source=SEARCH" style="text-decoration:none; display:block;">'+bar('검색 수집', src.SEARCH, total, 'var(--primary)')+'</a>' +
`<div class="fd-row"><span>발행완료</span><span class="v"><b>${fmt(pbs.PUBLISHED)}</b></span></div>`; '<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>';
if(window.lucide) lucide.createIcons(); 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(); loadDashboard();

View File

@ -18,8 +18,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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 --> <!-- CSS -->
<link rel="stylesheet" th:href="@{/css/variables.css}"> <link rel="stylesheet" th:href="@{/css/variables.css}">