- 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>
378 lines
22 KiB
HTML
378 lines
22 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 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()">×</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;">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
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>
|