h-lab/src/main/resources/templates/dashboard.html
hehihoho3@gmail.com b97e4644ea fix(ui): 대시보드 빈 화면 수정 — Thymeleaf [[ 충돌 제거
대시보드 인라인 스크립트의 `const stages = [['미검토',...],[...]]` 에서
앞쪽 `[[` 가 Thymeleaf 인라인 표현식 문법(`[[${...}]]`)과 충돌해,
렌더링이 그 지점에서 예외로 잘려 응답이 truncate → JS 미완성 → 빈 화면이었다.
(로그의 "response committed already" 예외가 이 증상)

배열 리터럴을 줄바꿈/공백으로 풀어 `[[`·`]]` 인접을 제거. 렌더 결과가
온전해짐(잘림 21217B → 정상 24519B, </html>·loadDashboard() 복구).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:38:28 +09:00

270 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 대시보드</title>
</head>
<body>
<div layout:fragment="content">
<div class="fd-dash">
<div class="fd-masthead">
<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>
</div>
</div>
<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>
</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); }
.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,t){ return t>0?Math.round((Number(n||0)/t)*100):0; }
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));
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>`;
}
async function loadDashboard(){
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; }
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('');
}
// ----- 파이프라인 + 병목 -----
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>`;
// ----- 출처/포맷 -----
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>`;
// ----- 발행 -----
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();
}
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();
/*]]>*/
</script>
</div>
</body>
</html>