h-lab/src/main/resources/templates/collection.html
hehihoho3@gmail.com fc58a32a20 feat(ui): collection filter toolbar — toggle chips + tidy inline selects
Replace raw checkboxes + stacked-label dropdowns with pill toggle chips
(.chip, accent when checked via :has) and a single aligned toolbar row;
result count as a badge. Adds reusable .toolbar/.chip/.field components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:45:05 +09:00

335 lines
20 KiB
HTML

<!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="page-header">
<div>
<h1>수집함</h1>
<p class="sub">수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.</p>
</div>
</div>
<!-- 카테고리 관리 -->
<div class="card mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">카테고리</h3>
<button class="btn btn-secondary p-2" onclick="document.getElementById('catForm').classList.toggle('hidden')">
<i data-lucide="plus" style="width:16px;"></i>
</button>
</div>
<div id="categoryChips" class="flex flex-wrap gap-2 mb-2">
<span class="text-muted text-sm">로딩...</span>
</div>
<div id="catForm" class="hidden flex gap-2 mt-4" style="flex-wrap: wrap; align-items:flex-end;">
<div style="display:flex; flex-direction:column; gap:4px;">
<label class="text-sm text-muted">이름</label>
<input id="catName" type="text" placeholder="예: 동물 썰"
style="padding:8px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none;">
</div>
<div style="display:flex; flex-direction:column; gap:4px;">
<label class="text-sm text-muted">색상</label>
<input id="catColor" type="color" value="#7C3AED"
style="padding:2px; height:42px; width:56px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md);">
</div>
<button class="btn btn-primary px-4 py-2" onclick="addCategory()">추가</button>
</div>
</div>
<!-- 필터 툴바 -->
<div class="card mb-4">
<div class="toolbar">
<label class="chip"><input type="checkbox" id="fOutperformers" onchange="loadVideos()"> 🚀 떡상 후보</label>
<label class="chip"><input type="checkbox" id="fShorts" onchange="loadVideos()"> Shorts</label>
<label class="chip"><input type="checkbox" id="fBookmarked" onchange="loadVideos()"> ⭐ 북마크</label>
<span class="sep"></span>
<label class="field">상태
<select id="fStatus" onchange="loadVideos()">
<option value="">전체</option>
<option value="NEW">NEW (수집됨)</option>
<option value="REVIEWING">REVIEWING (검토중)</option>
<option value="TARGET">TARGET (작업대상)</option>
<option value="DONE">DONE (완료)</option>
<option value="EXCLUDED">EXCLUDED (제외)</option>
</select>
</label>
<label class="field">카테고리
<select id="fCategory" onchange="loadVideos()"><option value="">전체</option></select>
</label>
<label class="field">출처
<select id="fSource" onchange="loadVideos()">
<option value="">전체</option>
<option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option>
</select>
</label>
<label class="field">정렬
<select id="fSort" onchange="loadVideos()">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option>
</select>
</label>
<span class="spacer"></span>
<span id="resultCount" class="badge badge-muted"></span>
<button class="btn btn-secondary" onclick="loadVideos()" title="새로고침">
<i data-lucide="refresh-cw" style="width:15px;"></i>
</button>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead style="background:var(--surface-2); border-bottom:1px solid var(--glass-border);">
<tr>
<th class="p-3 text-sm font-bold text-muted">썸네일</th>
<th class="p-3 text-sm font-bold text-muted">제목</th>
<th class="p-3 text-sm font-bold text-muted">채널</th>
<th class="p-3 text-sm font-bold text-muted">구독자</th>
<th class="p-3 text-sm font-bold text-muted">조회수</th>
<th class="p-3 text-sm font-bold text-muted">시간당</th>
<th class="p-3 text-sm font-bold text-muted">배율</th>
<th class="p-3 text-sm font-bold text-muted">상태</th>
<th class="p-3 text-sm font-bold text-muted">카테고리</th>
<th class="p-3 text-sm font-bold text-muted">관리</th>
</tr>
</thead>
<tbody id="resultBody">
<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 영상 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
</div>
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
</div>
</div>
<style>
.hidden { display:none !important; }
.row-sel { padding:4px 6px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.8rem; outline:none; }
.row-sel option { background:var(--surface); color:var(--text); }
.cat-chip { padding:4px 10px; border-radius:999px; font-size:0.8rem; display:inline-flex; align-items:center; gap:6px; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/v1/channel-videos';
let categories = [];
const STATUS_LABEL = { NEW:'NEW', REVIEWING:'검토중', TARGET:'작업대상', DONE:'완료', EXCLUDED:'제외' };
const STATUS_KEYS = ['NEW','REVIEWING','TARGET','DONE','EXCLUDED'];
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) ? '-' : Number(n).toLocaleString(); }
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
return json.data;
}
// ----- 카테고리 -----
async function loadCategories(){
try {
categories = await api('/api/v1/categories') || [];
} catch(e){ categories = []; }
// 필터 드롭다운
const fc = document.getElementById('fCategory');
const cur = fc.value;
fc.innerHTML = '<option value="">전체</option>' +
categories.map(c=>`<option value="${c.id}">${esc(c.name)}</option>`).join('');
fc.value = cur;
// 관리 칩
const chips = document.getElementById('categoryChips');
if(categories.length===0){
chips.innerHTML = '<span class="text-muted text-sm">카테고리가 없습니다. + 로 추가하세요.</span>';
} else {
chips.innerHTML = categories.map(c=>{
const color = c.color || '#64748b';
return `<span class="cat-chip" style="background:${color}22; border:1px solid ${color}55; color:${color};">
<span style="width:8px;height:8px;border-radius:50%;background:${color};"></span>${esc(c.name)}
<i data-lucide="x" style="width:13px;cursor:pointer;" onclick="deleteCategory(${c.id})"></i></span>`;
}).join('');
}
if(window.lucide) lucide.createIcons();
}
async function addCategory(){
const name = document.getElementById('catName').value.trim();
if(!name){ alert('이름을 입력하세요'); return; }
const color = document.getElementById('catColor').value;
try {
await api('/api/v1/categories', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name, color}) });
document.getElementById('catName').value='';
await loadCategories();
} catch(e){ alert('추가 실패: '+e.message); }
}
async function deleteCategory(id){
if(!confirm('이 카테고리를 삭제할까요? (분류된 영상은 분류만 해제됩니다)')) return;
try { await api('/api/v1/categories/'+id, {method:'DELETE'}); await loadCategories(); loadVideos(); }
catch(e){ alert('삭제 실패: '+e.message); }
}
// ----- 영상 목록 -----
async function loadVideos(){
const body = document.getElementById('resultBody');
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>';
const outperf = document.getElementById('fOutperformers').checked;
let data;
try {
if(outperf){
data = await api(API + '/outperformers?limit=100&minRatio=1');
} else {
const p = new URLSearchParams();
const status = document.getElementById('fStatus').value;
const cat = document.getElementById('fCategory').value;
const src = document.getElementById('fSource').value;
if(status) p.set('status', status);
if(cat) p.set('categoryId', cat);
if(src) p.set('source', src);
if(document.getElementById('fShorts').checked) p.set('shortsOnly','true');
if(document.getElementById('fBookmarked').checked) p.set('bookmarkedOnly','true');
p.set('sortBy', document.getElementById('fSort').value);
data = await api(API + '?' + p.toString());
}
} catch(e){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
return;
}
renderRows(data || []);
}
function ratioBadge(r){
if(r==null) return '<span class="text-muted">-</span>';
const v = Number(r);
let color = '#10b981';
if(v>=10) color = '#ef4444'; else if(v>=2) color = '#f59e0b';
return `<span style="color:${color}; font-weight:bold;">${v.toFixed(1)}x</span>`;
}
function renderRows(list){
const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 영상이 없습니다.</td></tr>';
return;
}
body.innerHTML = list.map(v=>{
const catOpts = '<option value="">-</option>' + categories.map(c=>
`<option value="${c.id}" ${v.categoryId===c.id?'selected':''}>${esc(c.name)}</option>`).join('');
const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span style="font-size:0.65rem; background:#7C3AED33; color:#a78bfa; padding:1px 5px; border-radius:4px; margin-left:4px;">Shorts</span>' : '';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3">
<div style="position:relative; cursor:pointer; width:96px;" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<img src="${esc(v.thumbnailUrl)}" style="width:96px; height:54px; object-fit:cover; border-radius:6px;">
</div>
</td>
<td class="p-3" style="max-width:280px;">
<div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline">${esc(v.title)}</a>${shortsBadge}
</div>
</td>
<td class="p-3 text-sm text-muted" style="max-width:130px;">
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:var(--text-2);">${esc(v.channelTitle||'-')}</a>
</td>
<td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewsPerHour)}</td>
<td class="p-3 text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td class="p-3"><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td class="p-3"><select class="row-sel" onchange="setCategory(${v.id}, this.value)">${catOpts}</select></td>
<td class="p-3">
<div class="flex items-center gap-1">
<a class="btn btn-primary p-2" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary p-2" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
<button class="btn btn-secondary p-2" title="삭제" onclick="deleteVideo(${v.id})"><i data-lucide="trash-2" style="width:15px;"></i></button>
</div>
</td>
</tr>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
// ----- 행 액션 -----
async function setStatus(id, value){
try { await api(API+'/'+id+'/status', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:value})}); }
catch(e){ alert('상태 변경 실패: '+e.message); }
}
async function setCategory(id, value){
const categoryId = value==='' ? null : Number(value);
try { await api(API+'/'+id+'/category', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({categoryId})}); }
catch(e){ alert('카테고리 지정 실패: '+e.message); }
}
async function toggleBookmark(id, current){
try { await api(API+'/'+id+'/bookmark', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({bookmarked: !current})}); loadVideos(); }
catch(e){ alert('북마크 실패: '+e.message); }
}
async function deleteVideo(id){
if(!confirm('수집함에서 이 영상을 제거할까요?')) return;
try { await api(API+'/'+id, {method:'DELETE'}); loadVideos(); }
catch(e){ alert('삭제 실패: '+e.message); }
}
// ----- 모달 -----
function openVideoModal(vid, title){
document.getElementById('modalTitle').textContent = title;
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+vid+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').style.display = 'flex';
}
function closeVideoModal(){
document.getElementById('iframeContainer').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
// ----- URL 파라미터 필터 적용 -----
function applyUrlFilters(){
const q = new URLSearchParams(window.location.search);
const setSel = (id, val) => {
if(val == null) return;
const el = document.getElementById(id);
if(!el) return;
if(el.tagName === 'SELECT'){
if([...el.options].some(o => o.value === String(val))) el.value = String(val);
} else { el.value = String(val); }
};
setSel('fStatus', q.get('status'));
setSel('fCategory', q.get('categoryId'));
setSel('fSource', q.get('source'));
if(q.get('shortsOnly') === 'true'){ const c = document.getElementById('fShorts'); if(c) c.checked = true; }
if(q.get('bookmarkedOnly') === 'true'){ const c = document.getElementById('fBookmarked'); if(c) c.checked = true; }
}
// init
(async ()=>{ await loadCategories(); applyUrlFilters(); await loadVideos(); })();
/*]]>*/
</script>
</div>
</body>
</html>