Compare commits
No commits in common. "97192d7372b756fe7091fed15440ece22df092d5" and "4508c3ef18a34a5cdaee94427370058c13752eb2" have entirely different histories.
97192d7372
...
4508c3ef18
@ -1,6 +0,0 @@
|
||||
# 푸시 테스트
|
||||
|
||||
Gitea 원격(`origin`, h-git.tolag.shop/hehihoho3/h-lab) 푸시가 정상 동작하는지 확인용 파일입니다.
|
||||
확인 후 삭제해도 됩니다.
|
||||
|
||||
- 작성: 2026-06-16
|
||||
@ -3,258 +3,251 @@
|
||||
layout:decorate="~{layout/base}">
|
||||
|
||||
<head>
|
||||
<title>h-lab - 대시보드</title>
|
||||
<title>h-lab - Dashboard</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="fd-dash">
|
||||
|
||||
<div class="fd-masthead">
|
||||
<header class="mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행</div>
|
||||
<h1>대시보드</h1>
|
||||
</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>
|
||||
<h1 class="text-xl font-bold mb-1">대시보드</h1>
|
||||
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<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 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>
|
||||
<!-- 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>
|
||||
<div id="funnel" class="flex flex-col gap-3">
|
||||
<div class="skeleton" style="height:48px;"></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: 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>
|
||||
|
||||
.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>
|
||||
<!-- 카테고리 / 출처·포맷 -->
|
||||
<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">수집 출처 & 포맷</h3>
|
||||
<div id="sourceFormat" class="flex flex-col gap-2"><div class="skeleton" style="height:48px;"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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 fmt(n){ return (n==null) ? '0' : Number(n).toLocaleString(); }
|
||||
function pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; }
|
||||
|
||||
async function getData(url, opts){
|
||||
const r = await fetch(url, opts);
|
||||
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);
|
||||
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(){ 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 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>`;
|
||||
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 ratioBadgeClass(r){ const v=Number(r); return v>=10?'badge-danger':(v>=2?'badge-warning':'badge-success'); }
|
||||
|
||||
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('leadList').innerHTML = '<div class="fd-empty" style="color:var(--fd-signal)">불러오기 실패: '+esc(e.message)+'</div>'; return; }
|
||||
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');
|
||||
}
|
||||
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||{};
|
||||
const total = Number(pipe.total||0);
|
||||
const pub = d.publish||{}, pbs = pub.byStatus||{};
|
||||
const op = d.outperformers||[];
|
||||
// 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);
|
||||
|
||||
// ----- 액션 바 -----
|
||||
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으로 보내세요 →';
|
||||
// 깔때기 (총 수집 대비) — 3-tone color scheme
|
||||
document.getElementById('funnelExcluded').textContent = '총 ' + fmt(total) + '건 · 제외 ' + fmt(bs.EXCLUDED);
|
||||
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)');
|
||||
|
||||
// ----- 떡상 리더보드 (실데이터 + 액션) -----
|
||||
const lead = document.getElementById('leadList');
|
||||
if(op.length===0){ lead.innerHTML = '<div class="fd-empty">아직 떡상 후보가 없습니다. 채널을 수집하면 배율 상위 영상이 여기 모입니다.</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 {
|
||||
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>`;
|
||||
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('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 =
|
||||
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>`;
|
||||
// 발행 현황 — 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('');
|
||||
|
||||
// ----- 출처/포맷 -----
|
||||
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>`;
|
||||
// 카테고리 분포 — 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>';
|
||||
}
|
||||
|
||||
// ----- 발행 -----
|
||||
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>`;
|
||||
// 출처 & 포맷 — 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>';
|
||||
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
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); }
|
||||
dashboardAnimated = true;
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
<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}">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user