h-lab/src/main/resources/templates/collection.html
hehihoho3@gmail.com 616003479b feat(ui): collection — compact category, help modal, fit-to-width table
- Category management collapsed into a slim single-row strip
- "사용법" button opens a reusable help modal (.modal-overlay/.modal-card +
  .help-item) explaining categories, filters, list view, and row actions
- Merge thumbnail+title+channel into one "영상" column and tighten cells so the
  whole table fits the viewport — no horizontal scroll on page or table card
- #mainContent min-width:0 to avoid flex overflow

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

378 lines
22 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="page-header">
<div>
<h1>수집함</h1>
<p class="sub">수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()">
<i data-lucide="help-circle" style="width:15px;"></i> 사용법
</button>
</div>
</div>
<!-- 카테고리 관리 (컴팩트) -->
<div class="card mb-4" style="padding:0.75rem 1.1rem;">
<div class="flex items-center gap-3" style="flex-wrap:wrap;">
<span class="text-sm font-semibold" style="color:var(--text-2);">카테고리</span>
<div id="categoryChips" class="flex flex-wrap gap-2" style="flex:1; min-width:120px;">
<span class="text-muted text-sm">로딩...</span>
</div>
<button class="btn btn-secondary" style="padding:0.4rem 0.7rem;" onclick="document.getElementById('catForm').classList.toggle('hidden')">
<i data-lucide="plus" style="width:15px;"></i> 추가
</button>
</div>
<div id="catForm" class="hidden" style="display:flex; gap:0.6rem; flex-wrap:wrap; align-items:center; margin-top:0.7rem; padding-top:0.7rem; border-top:1px solid var(--border);">
<input id="catName" type="text" placeholder="이름 (예: 동물 썰)" style="flex:1; min-width:140px;">
<input id="catColor" type="color" value="#7C3AED" title="색상" style="padding:2px; height:38px; width:48px;">
<button class="btn btn-primary" 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 coll-table" style="border-collapse:collapse; text-align:left;">
<thead>
<tr>
<th>영상</th>
<th>구독자</th>
<th>조회수</th>
<th>시간당</th>
<th>배율</th>
<th>상태</th>
<th>카테고리</th>
<th style="text-align:right;">관리</th>
</tr>
</thead>
<tbody id="resultBody">
<tr><td colspan="8" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head">
<h3>📖 수집함 사용법</h3>
<button class="modal-close" onclick="closeHelp()">&times;</button>
</div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="tag"></i></div>
<div>
<div class="hi-t">카테고리</div>
<div class="hi-d">영상을 주제별로 분류하는 라벨입니다. 우측 <b>[+ 추가]</b>로 이름·색을 정해 만들고, 칩의 <b>×</b>로 삭제합니다. 표의 <b>‘카테고리’ 드롭다운</b>으로 각 영상에 지정하세요.</div>
</div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="filter"></i></div>
<div>
<div class="hi-t">필터 · 정렬</div>
<div class="hi-d"><b></b>(🚀 떡상 후보 · Shorts · ⭐ 북마크)을 눌러 켜고 끕니다. 드롭다운으로 <b>상태·카테고리·출처</b>를 좁히고 <b>정렬</b>(배율/시간당/조회수/최신순)을 바꿉니다. 우측 배지에 결과 수가 표시됩니다.</div>
</div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="table-2"></i></div>
<div>
<div class="hi-t">목록 보기</div>
<div class="hi-d"><b>썸네일 클릭</b> = 팝업 미리보기, <b>제목 클릭</b> = YouTube 새 탭. <b>배율</b>은 조회수÷구독자로, 높을수록 ‘떡상’(10x↑ 빨강, 2x↑ 주황). 상태·카테고리 드롭다운은 바꾸면 <b>즉시 저장</b>됩니다.</div>
</div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="mouse-pointer-click"></i></div>
<div>
<div class="hi-t">행 버튼 (관리)</div>
<div class="hi-d"><b>🪄 재가공</b> = 자막 추출·재작성 에디터로 이동 · <b>⭐ 북마크</b> = 즐겨찾기 토글 · <b>🗑 삭제</b> = 수집함에서 제거(스크립트도 함께 삭제).</div>
</div>
</div>
</div>
</div>
</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(--border-strong); border-radius:6px; color:var(--text); font-size:0.78rem; outline:none; max-width:110px; }
.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; }
/* 컴팩트 표: 숫자/상태 칸은 줄바꿈 없이, 영상 칸만 넓게 */
.coll-table th, .coll-table td { white-space:nowrap; vertical-align:middle; }
.coll-table td:first-child, .coll-table th:first-child { white-space:normal; width:42%; min-width:300px; }
.coll-table th { background:var(--surface-2); }
</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="8" 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 class="badge badge-primary" style="margin-left:5px;">Shorts</span>' : '';
return `<tr>
<td>
<div class="flex items-center gap-3" style="min-width:0;">
<img src="${esc(v.thumbnailUrl)}" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)"
style="width:84px; height:47px; object-fit:cover; border-radius:6px; cursor:pointer; flex-shrink:0;">
<div style="min-width:0;">
<div class="font-semibold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.35;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline">${esc(v.title)}</a>${shortsBadge}
</div>
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:var(--text-3); font-size:0.72rem; display:block; margin-top:2px;">${esc(v.channelTitle||'-')}</a>
</div>
</div>
</td>
<td class="text-sm">${fmt(v.subscriberCount)}</td>
<td class="text-sm">${fmt(v.viewCount)}</td>
<td class="text-sm">${fmt(v.viewsPerHour)}</td>
<td class="text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td><select class="row-sel" onchange="setCategory(${v.id}, this.value)">${catOpts}</select></td>
<td>
<div class="flex items-center gap-1 justify-end">
<a class="btn btn-primary" style="padding:0.4rem;" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary" style="padding:0.4rem;" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
<button class="btn btn-secondary" style="padding:0.4rem;" 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';
}
// ----- 사용법 모달 -----
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeHelp(); closeVideoModal(); } });
// ----- 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>