h-lab/src/main/resources/templates/dashboard.html
hehihoho3@gmail.com f22f7028c4 refactor: 발행/유통(발행 큐) 기능 제거 — 예능 전환
유통(내가 올려서 발행)에서 예능 소재 파이프라인으로 방향을 틀면서
발행 큐 기능을 제거한다. domain/publish 전체와 발행 준비 UI(재가공 화면),
사이드바 발행 큐 링크, 대시보드 발행 현황 위젯을 걷어냈다.

- domain/publish/* (Controller/Service/Repository/Entity) 삭제
- publish.html · PublishServiceTest 삭제
- rework.html 발행 준비 섹션·JS 제거
- dashboard.html · DashboardService 발행 집계 제거
- WebController /publish 라우트, sidebar 링크 제거
- ChannelVideo.publishedAt(원본 게시일)은 발행 기능과 무관하여 그대로 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:13:46 +09:00

309 lines
21 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>
</section>
</div>
<!-- 영상 미리보기 모달 -->
<div id="videoModal" class="fd-vmodal" onclick="if(event.target===this) closeVideoModal()">
<div class="fd-vmodal-card">
<div class="fd-vmodal-head">
<span class="fd-vmodal-title" id="vmTitle">미리보기</span>
<button class="fd-vmodal-x" onclick="closeVideoModal()" aria-label="닫기">&times;</button>
</div>
<div id="vmFrame" class="fd-vmodal-frame"></div>
</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); }
.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; }
/* 영상 미리보기 모달 */
.fd-vmodal{ display:none; position:fixed; inset:0; z-index:1000; background:rgba(10,10,12,.86); align-items:center; justify-content:center; padding:20px; }
.fd-vmodal.open{ display:flex; }
.fd-vmodal-card{ background:var(--fd-panel); border:1.5px solid var(--fd-rule-strong); box-shadow:0 20px 60px rgba(0,0,0,.4); }
.fd-vmodal-head{ display:flex; justify-content:space-between; align-items:center; gap:14px; padding:11px 14px; border-bottom:1.5px solid var(--fd-rule-strong); }
.fd-vmodal-title{ font-weight:600; font-size:13px; color:var(--fd-ink); max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.fd-vmodal-x{ background:none; border:none; font-size:24px; line-height:1; cursor:pointer; color:var(--fd-ink-2); padding:0 2px; }
.fd-vmodal-x:hover{ color:var(--fd-ink); }
.fd-vmodal-frame{ width:min(360px,90vw); aspect-ratio:9/16; max-height:78vh; background:#000; }
.fd-vmodal-frame iframe{ width:100%; height:100%; border:0; display:block; }
@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); } }
@media (max-width:680px){
.fd-dash{ margin:-1.25rem; }
.fd-masthead{ flex-direction:column; align-items:stretch; gap:8px; padding:16px 16px 12px; }
.fd-masthead .fd-tick{ display:none; } /* 긴 브레드크럼/라벨 숨김 */
.fd-masthead h1{ font-size:26px; }
.fd-meta{ display:flex; align-items:center; gap:12px; }
.fd-meta .fd-big{ margin:0; }
.fd-body{ padding:16px 16px 28px; }
.fd-num{ font-size:44px; }
.fd-act{ padding:16px 16px; }
/* 리더보드 행: 배율까지 한 줄, 액션 버튼은 아랫줄 우측 정렬 */
.fd-lead{ grid-template-columns:24px 46px 1fr auto; column-gap:10px; }
.fd-lead .fd-actcell{ grid-column:1 / -1; justify-content:flex-end; opacity:1; padding-top:2px; }
.fd-lead .fd-t{ white-space:normal; max-width:none; }
}
</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 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="" title="미리보기" style="cursor:pointer;"
data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<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]
];
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) +
`<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>`;
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); }
}
// ----- 영상 미리보기 (수집함과 동일) -----
function openVideoModal(vid, title){
document.getElementById('vmTitle').textContent = title || '미리보기';
document.getElementById('vmFrame').innerHTML =
'<iframe src="https://www.youtube.com/embed/' + vid + '?autoplay=1" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').classList.add('open');
}
function closeVideoModal(){
document.getElementById('vmFrame').innerHTML = '';
document.getElementById('videoModal').classList.remove('open');
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
loadDashboard();
/*]]>*/
</script>
</div>
</body>
</html>