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>
335 lines
20 KiB
HTML
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;">×</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,'&').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="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>
|