내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어가서 등록을 취소했다. 그러면 해시태그 역분석의 원천이 사라지는데, 기존 코드는 키워드 0개로 검색 루프를 그냥 빠져나와 "발굴했지만 아무것도 못 찾음"처럼 보였다. - 역분석할 해시태그가 없으면 이유를 명시한 오류를 던진다. - 피드 화면의 '후보 자동 발굴'은 먼저 감지된 키워드를 보여주고 확인을 받는다. 키워드가 없으면 프로그램 이름을 직접 입력받아 검색한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
691 lines
36 KiB
HTML
691 lines
36 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="openSeeds()">
|
|
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
|
|
</button>
|
|
<button class="btn btn-primary" id="collectBtn" onclick="runCollect()">
|
|
<i data-lucide="refresh-cw" style="width:15px;"></i> 지금 수집
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 탭 -->
|
|
<div class="feed-tabs" role="tablist" aria-label="피드 종류">
|
|
<button class="feed-tab" role="tab" id="tab-SOURCE" data-tab="SOURCE"
|
|
aria-selected="true" aria-controls="feedGrid" onclick="switchTab('SOURCE')">
|
|
<i data-lucide="film" style="width:15px;"></i>
|
|
<span>소재 원본</span>
|
|
<span class="feed-tab-hint">웹예능 롱폼</span>
|
|
</button>
|
|
<button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL"
|
|
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')">
|
|
<i data-lucide="swords" style="width:15px;"></i>
|
|
<span>경쟁 쇼츠</span>
|
|
<span class="feed-tab-hint">예능짤 채널</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 필터 -->
|
|
<div class="card mb-4">
|
|
<div class="toolbar">
|
|
<label class="field" for="fProgram">프로그램
|
|
<select id="fProgram" onchange="applyFilters()">
|
|
<option value="">전체</option>
|
|
</select>
|
|
</label>
|
|
<label class="field" for="fLength" id="lengthField">길이
|
|
<select id="fLength" onchange="applyFilters()">
|
|
<option value="">전체</option>
|
|
<option value="CLIP">공식클립 (~15분)</option>
|
|
<option value="FULL">풀에피 (15분+)</option>
|
|
</select>
|
|
</label>
|
|
<label class="field" for="fDays">기간
|
|
<select id="fDays" onchange="applyFilters()">
|
|
<option value="1">최근 24시간</option>
|
|
<option value="3">최근 3일</option>
|
|
<option value="7" selected>최근 7일</option>
|
|
<option value="14">최근 14일</option>
|
|
<option value="0">전체</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label class="chip">
|
|
<input type="checkbox" id="fHideWorked" onchange="applyFilters()">
|
|
<i data-lucide="eye-off"></i> 작업한 것 숨기기
|
|
</label>
|
|
|
|
<span class="spacer"></span>
|
|
<span id="resultCount" class="badge badge-muted" aria-live="polite">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 결과 -->
|
|
<div id="feedGrid" class="feed-grid" role="tabpanel" aria-labelledby="tab-SOURCE"></div>
|
|
<div id="feedState" class="feed-state hidden"></div>
|
|
|
|
<!-- 시드 관리 모달 -->
|
|
<div id="seedModal" class="modal-overlay" onclick="if(event.target===this) closeSeeds()">
|
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="seedModalTitle">
|
|
<div class="modal-head">
|
|
<h3 id="seedModalTitle">시드 관리</h3>
|
|
<button class="modal-close" onclick="closeSeeds()" aria-label="닫기">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
|
|
<b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널(롱폼만 수집),
|
|
<b>경쟁 쇼츠</b>는 같은 소재를 다루는 예능짤 채널(쇼츠만 수집)입니다.
|
|
</p>
|
|
|
|
<div class="seed-add">
|
|
<label class="sr-only" for="seedUrl">채널 URL</label>
|
|
<input id="seedUrl" type="text" placeholder="https://www.youtube.com/@channel">
|
|
<label class="sr-only" for="seedRole">역할</label>
|
|
<select id="seedRole">
|
|
<option value="SOURCE">소재 원본</option>
|
|
<option value="RIVAL">경쟁 쇼츠</option>
|
|
</select>
|
|
<button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button>
|
|
</div>
|
|
<p class="text-xs text-muted mt-2" id="seedAddHelp">
|
|
채널 URL 또는 @핸들 주소를 붙여넣으세요.
|
|
</p>
|
|
|
|
<div class="seed-divider"></div>
|
|
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-semibold">등록된 시드</span>
|
|
<button class="btn btn-secondary" style="padding:0.35rem 0.6rem;" onclick="suggestSeeds()"
|
|
title="내 채널 해시태그를 역분석해 소재 원본 후보를 찾습니다 (키워드당 100 units)">
|
|
<i data-lucide="wand-sparkles" style="width:14px;"></i> 후보 자동 발굴
|
|
</button>
|
|
</div>
|
|
<div id="seedList" class="seed-list"><div class="text-sm text-muted p-4">로딩 중...</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 토스트 -->
|
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
|
|
<style>
|
|
/* ===== 탭 ===== */
|
|
.feed-tabs { display:flex; gap:0.4rem; margin-bottom:1rem; border-bottom:1px solid var(--border); }
|
|
.feed-tab {
|
|
display:inline-flex; align-items:center; gap:0.45rem;
|
|
padding:0.7rem 1rem; min-height:44px;
|
|
background:transparent; border:none; border-bottom:2px solid transparent;
|
|
color:var(--text-2); font-size:0.9rem; font-weight:600; font-family:inherit;
|
|
cursor:pointer; transition:color .18s ease, border-color .18s ease;
|
|
}
|
|
.feed-tab:hover { color:var(--text); }
|
|
.feed-tab[aria-selected="true"] { color:var(--accent); border-bottom-color:var(--accent); }
|
|
.feed-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
|
|
.feed-tab-hint { font-size:0.72rem; font-weight:500; color:var(--text-3); }
|
|
.feed-tab[aria-selected="true"] .feed-tab-hint { color:var(--accent); opacity:.75; }
|
|
|
|
/* ===== 카드 그리드 ===== */
|
|
.feed-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(268px, 1fr)); gap:1rem; }
|
|
@media (max-width: 520px) { .feed-grid { grid-template-columns:1fr; } }
|
|
|
|
.fcard {
|
|
display:flex; flex-direction:column;
|
|
background:var(--surface); border:1px solid var(--border); border-radius:var(--r);
|
|
overflow:hidden; transition:border-color .18s ease, box-shadow .18s ease;
|
|
}
|
|
.fcard:hover { border-color:var(--border-strong); box-shadow:var(--shadow); }
|
|
.fcard.is-worked { opacity:.55; }
|
|
.fcard.is-worked:hover { opacity:1; }
|
|
|
|
.fthumb { position:relative; display:block; background:var(--inset); aspect-ratio:16/9; }
|
|
.fthumb img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
.fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
|
.fdur {
|
|
position:absolute; right:6px; bottom:6px;
|
|
background:rgba(0,0,0,.78); color:#fff;
|
|
font-size:0.7rem; font-weight:600; font-family:var(--font-mono);
|
|
padding:1px 5px; border-radius:4px; line-height:1.5;
|
|
}
|
|
.fgolden {
|
|
position:absolute; left:6px; top:6px;
|
|
display:inline-flex; align-items:center; gap:3px;
|
|
background:var(--accent); color:#fff;
|
|
font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5;
|
|
}
|
|
.fgolden svg { width:11px; height:11px; }
|
|
|
|
.fbody { padding:0.7rem 0.8rem 0.55rem; display:flex; flex-direction:column; gap:0.35rem; flex:1; }
|
|
.fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; }
|
|
.fmeta .fprog { color:var(--text-2); font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.ftitle {
|
|
font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text);
|
|
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
|
}
|
|
.ftitle:hover { text-decoration:underline; }
|
|
.fstats { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; font-size:0.72rem; color:var(--text-3); }
|
|
.fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
|
|
|
|
.factions {
|
|
display:flex; align-items:center; gap:0.4rem;
|
|
padding:0.5rem 0.8rem 0.7rem;
|
|
}
|
|
.factions .btn { padding:0.5rem 0.7rem; min-height:38px; font-size:0.78rem; }
|
|
.factions .icon-btn {
|
|
display:inline-flex; align-items:center; justify-content:center;
|
|
width:38px; height:38px; flex-shrink:0;
|
|
background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-sm);
|
|
color:var(--text-2); cursor:pointer; transition:all .18s ease;
|
|
}
|
|
.factions .icon-btn:hover { background:var(--surface-2); color:var(--text); }
|
|
.factions .icon-btn.on { color:var(--warning); border-color:var(--warning); }
|
|
.factions .grow { flex:1; }
|
|
|
|
/* ===== 상태(로딩/빈/에러) ===== */
|
|
.feed-state { padding:3rem 1rem; text-align:center; color:var(--text-2); }
|
|
.feed-state .fs-ic {
|
|
width:44px; height:44px; margin:0 auto 0.9rem; border-radius:12px;
|
|
background:var(--surface-2); color:var(--text-3);
|
|
display:flex; align-items:center; justify-content:center;
|
|
}
|
|
.feed-state .fs-t { font-weight:600; color:var(--text); margin-bottom:0.35rem; }
|
|
.feed-state .fs-d { font-size:0.85rem; line-height:1.6; }
|
|
.feed-state .fs-a { margin-top:1rem; display:flex; gap:0.5rem; justify-content:center; flex-wrap:wrap; }
|
|
|
|
.skel-card { border:1px solid var(--border); border-radius:var(--r); overflow:hidden; background:var(--surface); }
|
|
.skel-card .sk-thumb { aspect-ratio:16/9; }
|
|
.skel-card .sk-line { height:11px; margin:0.6rem 0.8rem; border-radius:4px; }
|
|
.skel-card .sk-line.short { width:45%; }
|
|
|
|
/* ===== 시드 모달 ===== */
|
|
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
|
.seed-add input { flex:1; min-width:170px; }
|
|
.seed-divider { height:1px; background:var(--border); margin:1.1rem 0 0.9rem; }
|
|
.seed-list { display:flex; flex-direction:column; gap:0.45rem; max-height:340px; overflow-y:auto; }
|
|
.seed-row {
|
|
display:flex; align-items:center; gap:0.6rem;
|
|
padding:0.55rem 0.7rem; border:1px solid var(--border); border-radius:var(--r-sm); background:var(--surface);
|
|
}
|
|
.seed-row img { width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; background:var(--inset); }
|
|
.seed-row .sname { flex:1; min-width:0; }
|
|
.seed-row .sname b { display:block; font-size:0.83rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.seed-row .sname span { font-size:0.72rem; color:var(--text-3); }
|
|
.seed-row button {
|
|
display:inline-flex; align-items:center; justify-content:center;
|
|
width:34px; height:34px; flex-shrink:0;
|
|
background:transparent; border:1px solid var(--border); border-radius:var(--r-sm);
|
|
color:var(--text-3); cursor:pointer; transition:all .18s ease;
|
|
}
|
|
.seed-row button:hover { color:var(--danger); border-color:var(--danger); }
|
|
.seed-row button.warn:hover { color:var(--warning); border-color:var(--warning); }
|
|
|
|
/* ===== 토스트 ===== */
|
|
.toast {
|
|
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
|
|
background:var(--text); color:var(--bg);
|
|
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
|
|
box-shadow:var(--shadow-lg); z-index:2000;
|
|
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
|
|
max-width:88vw; text-align:center;
|
|
}
|
|
.toast.show { opacity:1; transform:translate(-50%, 0); }
|
|
.toast.err { background:var(--danger); color:#fff; }
|
|
|
|
.sr-only {
|
|
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
|
|
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; }
|
|
}
|
|
</style>
|
|
|
|
<script th:inline="javascript">
|
|
/*<![CDATA[*/
|
|
const API = '/api/feed';
|
|
const CV_API = '/api/v1/channel-videos';
|
|
|
|
let currentTab = 'SOURCE';
|
|
let items = [];
|
|
|
|
// ---------- 공통 ----------
|
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
function fmtNum(n){
|
|
if(n==null) return '-';
|
|
const v = Number(n);
|
|
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억';
|
|
if(v >= 10000) return (v/10000).toFixed(1).replace(/\.0$/,'') + '만';
|
|
return v.toLocaleString();
|
|
}
|
|
function fmtDur(sec){
|
|
if(sec==null || sec<=0) return '';
|
|
const h = Math.floor(sec/3600), m = Math.floor((sec%3600)/60), s = sec%60;
|
|
const pad = v => String(v).padStart(2,'0');
|
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
|
}
|
|
function fmtAgo(iso){
|
|
if(!iso) return '-';
|
|
const then = new Date(iso).getTime();
|
|
if(isNaN(then)) return '-';
|
|
const mins = Math.max(0, Math.floor((Date.now() - then) / 60000));
|
|
if(mins < 60) return `${mins}분 전`;
|
|
const hrs = Math.floor(mins/60);
|
|
if(hrs < 24) return `${hrs}시간 전`;
|
|
const days = Math.floor(hrs/24);
|
|
if(days < 30) return `${days}일 전`;
|
|
return new Date(then).toLocaleDateString('ko-KR');
|
|
}
|
|
|
|
let toastTimer = null;
|
|
function toast(msg, isError){
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.classList.toggle('err', !!isError);
|
|
el.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ---------- 상태 표시 ----------
|
|
function showState(icon, title, desc, actionsHtml){
|
|
document.getElementById('feedGrid').innerHTML = '';
|
|
const el = document.getElementById('feedState');
|
|
el.classList.remove('hidden');
|
|
el.innerHTML = `<div class="fs-ic"><i data-lucide="${icon}"></i></div>
|
|
<div class="fs-t">${esc(title)}</div>
|
|
<div class="fs-d">${desc}</div>
|
|
${actionsHtml ? `<div class="fs-a">${actionsHtml}</div>` : ''}`;
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
function hideState(){ document.getElementById('feedState').classList.add('hidden'); }
|
|
|
|
function showSkeleton(){
|
|
hideState();
|
|
const one = `<div class="skel-card">
|
|
<div class="sk-thumb skeleton"></div>
|
|
<div class="sk-line skeleton"></div>
|
|
<div class="sk-line short skeleton"></div>
|
|
</div>`;
|
|
document.getElementById('feedGrid').innerHTML = one.repeat(8);
|
|
}
|
|
|
|
// ---------- 탭 ----------
|
|
function switchTab(tab){
|
|
if(tab === currentTab) return;
|
|
currentTab = tab;
|
|
document.querySelectorAll('.feed-tab').forEach(btn => {
|
|
const on = btn.dataset.tab === tab;
|
|
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
btn.tabIndex = on ? 0 : -1;
|
|
});
|
|
document.getElementById('feedGrid').setAttribute('aria-labelledby', 'tab-' + tab);
|
|
// 경쟁 탭은 전부 쇼츠라 길이 필터가 의미 없다
|
|
const lf = document.getElementById('lengthField');
|
|
lf.style.display = (tab === 'RIVAL') ? 'none' : '';
|
|
if(tab === 'RIVAL') document.getElementById('fLength').value = '';
|
|
// 탭마다 프로그램 목록이 다르므로 선택을 비우고 다시 채운다
|
|
document.getElementById('fProgram').value = '';
|
|
loadPrograms().then(applyFilters);
|
|
}
|
|
|
|
document.querySelector('.feed-tabs').addEventListener('keydown', e => {
|
|
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
e.preventDefault();
|
|
const next = currentTab === 'SOURCE' ? 'RIVAL' : 'SOURCE';
|
|
switchTab(next);
|
|
document.getElementById('tab-' + next).focus();
|
|
});
|
|
|
|
// ---------- 필터 ----------
|
|
function readFilters(){
|
|
return {
|
|
tab: currentTab,
|
|
channelId: document.getElementById('fProgram').value,
|
|
lengthBucket: document.getElementById('fLength').value,
|
|
days: document.getElementById('fDays').value,
|
|
hideWorked: document.getElementById('fHideWorked').checked
|
|
};
|
|
}
|
|
|
|
function applyFilters(){
|
|
const f = readFilters();
|
|
// 새로고침·뒤로가기에서 필터가 보존되도록 URL 에 반영
|
|
const q = new URLSearchParams();
|
|
q.set('tab', f.tab);
|
|
if(f.channelId) q.set('channelId', f.channelId);
|
|
if(f.lengthBucket) q.set('lengthBucket', f.lengthBucket);
|
|
if(f.days !== '7') q.set('days', f.days);
|
|
if(f.hideWorked) q.set('hideWorked', 'true');
|
|
history.replaceState(null, '', location.pathname + (q.toString() ? '?'+q.toString() : ''));
|
|
loadFeed();
|
|
}
|
|
|
|
function restoreFilters(){
|
|
const q = new URLSearchParams(location.search);
|
|
const tab = q.get('tab');
|
|
if(tab === 'RIVAL'){
|
|
currentTab = 'RIVAL';
|
|
document.querySelectorAll('.feed-tab').forEach(btn => {
|
|
const on = btn.dataset.tab === 'RIVAL';
|
|
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
btn.tabIndex = on ? 0 : -1;
|
|
});
|
|
document.getElementById('lengthField').style.display = 'none';
|
|
}
|
|
if(q.get('days') != null) document.getElementById('fDays').value = q.get('days');
|
|
if(q.get('lengthBucket')) document.getElementById('fLength').value = q.get('lengthBucket');
|
|
if(q.get('hideWorked') === 'true') document.getElementById('fHideWorked').checked = true;
|
|
return q.get('channelId'); // 프로그램 목록 로드 후 적용
|
|
}
|
|
|
|
// ---------- 프로그램 목록 ----------
|
|
async function loadPrograms(preselect){
|
|
const sel = document.getElementById('fProgram');
|
|
const keep = preselect || sel.value;
|
|
let list = [];
|
|
try { list = await api(`${API}/programs?tab=${currentTab}`) || []; } catch(e){ list = []; }
|
|
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
|
|
`<option value="${esc(p.ytChannelId)}">${esc(p.channelTitle||'(이름 없음)')} (${p.videoCount})</option>`).join('');
|
|
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
|
}
|
|
|
|
// ---------- 피드 ----------
|
|
async function loadFeed(){
|
|
showSkeleton();
|
|
const f = readFilters();
|
|
const p = new URLSearchParams();
|
|
p.set('tab', f.tab);
|
|
if(f.channelId) p.set('channelId', f.channelId);
|
|
if(f.lengthBucket) p.set('lengthBucket', f.lengthBucket);
|
|
if(f.days && f.days !== '0') p.set('days', f.days);
|
|
if(f.hideWorked) p.set('hideWorked', 'true');
|
|
|
|
try {
|
|
items = await api(API + '?' + p.toString()) || [];
|
|
} catch(e){
|
|
document.getElementById('resultCount').textContent = '—';
|
|
showState('alert-triangle', '불러오지 못했습니다', esc(e.message),
|
|
'<button class="btn btn-secondary" onclick="loadFeed()">다시 시도</button>');
|
|
return;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function badgeHtml(it){
|
|
const out = [];
|
|
if(currentTab === 'SOURCE'){
|
|
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-primary">공식클립</span>');
|
|
else if(it.lengthBucket === 'FULL') out.push('<span class="badge badge-muted">풀에피</span>');
|
|
} else if(it.rising){
|
|
out.push('<span class="badge badge-warning"><i data-lucide="trending-up" style="width:11px;"></i>떡상중</span>');
|
|
}
|
|
if(it.worked) out.push(`<span class="badge badge-success">작업함</span>`);
|
|
return out.join('');
|
|
}
|
|
|
|
function cardHtml(it){
|
|
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
|
|
const dur = fmtDur(it.durationSec);
|
|
const golden = it.goldenTime
|
|
? '<span class="fgolden"><i data-lucide="zap"></i>골든타임</span>' : '';
|
|
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
|
|
? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : '';
|
|
|
|
// 소재 원본은 재가공 스튜디오로, 경쟁 쇼츠는 원본 보기로 (남의 쇼츠는 재가공 대상이 아님)
|
|
const primary = currentTab === 'SOURCE'
|
|
? `<a class="btn btn-primary grow" href="/rework/${it.id}">
|
|
<i data-lucide="wand-2" style="width:14px;"></i> 작업 시작</a>`
|
|
: `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
|
|
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`;
|
|
|
|
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
|
|
<a class="fthumb" href="${url}" target="_blank" rel="noopener"
|
|
aria-label="${esc(it.title)} — YouTube 에서 열기">
|
|
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
|
|
${golden}
|
|
${dur ? `<span class="fdur">${dur}</span>` : ''}
|
|
</a>
|
|
<div class="fbody">
|
|
<div class="fmeta">
|
|
<span class="fprog">${esc(it.channelTitle || '-')}</span>
|
|
<span>·</span>
|
|
<span>${fmtAgo(it.publishedAt)}</span>
|
|
</div>
|
|
<a class="ftitle" href="${url}" target="_blank" rel="noopener">${esc(it.title)}</a>
|
|
<div class="fstats">
|
|
<span class="num">${fmtNum(it.viewCount)}회</span>${vph}
|
|
${badgeHtml(it)}
|
|
</div>
|
|
</div>
|
|
<div class="factions">
|
|
${primary}
|
|
<button class="icon-btn${it.bookmarked ? ' on' : ''}" title="북마크"
|
|
aria-label="북마크" aria-pressed="${it.bookmarked}"
|
|
onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})">
|
|
<i data-lucide="star" style="width:16px;"></i>
|
|
</button>
|
|
<button class="icon-btn" title="숨김" aria-label="이 소재 숨기기"
|
|
onclick="hideItem(${it.id})">
|
|
<i data-lucide="eye-off" style="width:16px;"></i>
|
|
</button>
|
|
</div>
|
|
</article>`;
|
|
}
|
|
|
|
function render(){
|
|
document.getElementById('resultCount').textContent = items.length + '건';
|
|
if(items.length === 0){
|
|
const isSource = currentTab === 'SOURCE';
|
|
showState('inbox', '아직 소재가 없습니다',
|
|
isSource
|
|
? '소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.'
|
|
: '경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.',
|
|
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
|
|
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
|
|
return;
|
|
}
|
|
hideState();
|
|
document.getElementById('feedGrid').innerHTML = items.map(cardHtml).join('');
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
// ---------- 카드 액션 ----------
|
|
async function toggleBookmark(id, current){
|
|
try {
|
|
await api(`${CV_API}/${id}/bookmark`, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ bookmarked: !current })
|
|
});
|
|
const it = items.find(v => v.id === id);
|
|
if(it) it.bookmarked = !current;
|
|
render();
|
|
toast(!current ? '북마크에 담았습니다' : '북마크를 해제했습니다');
|
|
} catch(e){ toast('북마크 실패: ' + e.message, true); }
|
|
}
|
|
|
|
async function hideItem(id){
|
|
try {
|
|
await api(`${CV_API}/${id}/status`, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ status:'EXCLUDED' })
|
|
});
|
|
items = items.filter(v => v.id !== id);
|
|
render();
|
|
toast('숨겼습니다 — 되돌리려면 수집함에서 상태를 바꾸세요');
|
|
} catch(e){ toast('숨김 실패: ' + e.message, true); }
|
|
}
|
|
|
|
// ---------- 수집 ----------
|
|
async function runCollect(){
|
|
const btn = document.getElementById('collectBtn');
|
|
btn.disabled = true;
|
|
const original = btn.innerHTML;
|
|
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
|
|
if(window.lucide) lucide.createIcons();
|
|
try {
|
|
const r = await api(API + '/collect', { method:'POST' });
|
|
toast(`수집 완료 — 시드 ${r.seeds}개 중 ${r.collected}개, 영상 ${r.savedVideos}건`);
|
|
await loadPrograms();
|
|
await loadFeed();
|
|
} catch(e){
|
|
toast('수집 실패: ' + e.message, true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = original;
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
// ---------- 시드 관리 ----------
|
|
function openSeeds(){
|
|
document.getElementById('seedModal').classList.add('open');
|
|
loadSeeds();
|
|
setTimeout(()=> document.getElementById('seedUrl').focus(), 50);
|
|
}
|
|
function closeSeeds(){ document.getElementById('seedModal').classList.remove('open'); }
|
|
|
|
async function loadSeeds(){
|
|
const box = document.getElementById('seedList');
|
|
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
|
|
let list = [];
|
|
try { list = await api(API + '/seeds') || []; }
|
|
catch(e){ box.innerHTML = `<div class="text-sm text-danger p-4">불러오기 실패: ${esc(e.message)}</div>`; return; }
|
|
|
|
if(list.length === 0){
|
|
box.innerHTML = '<div class="text-sm text-muted p-4">등록된 시드가 없습니다. 위에서 채널 URL 을 추가하세요.</div>';
|
|
return;
|
|
}
|
|
box.innerHTML = list.map(s => {
|
|
const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 쇼츠';
|
|
const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted';
|
|
const warn = s.disabled
|
|
? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화"
|
|
aria-label="시드 다시 활성화" onclick="resetSeed(${s.id})">
|
|
<i data-lucide="rotate-ccw" style="width:15px;"></i></button>`
|
|
: '';
|
|
return `<div class="seed-row">
|
|
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
|
|
<div class="sname">
|
|
<b>${esc(s.title)}</b>
|
|
<span>구독 ${fmtNum(s.subscriberCount)}${s.disabled ? ' · 수집 중단됨' : ''}</span>
|
|
</div>
|
|
<span class="badge ${roleCls}">${roleLabel}</span>
|
|
${warn}
|
|
<button title="시드 해제" aria-label="시드 해제" onclick="removeSeed(${s.id}, '${esc(s.title)}')">
|
|
<i data-lucide="trash-2" style="width:15px;"></i></button>
|
|
</div>`;
|
|
}).join('');
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
async function addSeed(){
|
|
const url = document.getElementById('seedUrl').value.trim();
|
|
const help = document.getElementById('seedAddHelp');
|
|
if(!url){
|
|
help.textContent = '채널 URL 을 입력하세요.';
|
|
help.style.color = 'var(--danger)';
|
|
document.getElementById('seedUrl').focus();
|
|
return;
|
|
}
|
|
help.textContent = '채널 URL 또는 @핸들 주소를 붙여넣으세요.';
|
|
help.style.color = '';
|
|
|
|
const btn = document.getElementById('seedAddBtn');
|
|
btn.disabled = true;
|
|
try {
|
|
const role = document.getElementById('seedRole').value;
|
|
const s = await api(API + '/seeds', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ url, role })
|
|
});
|
|
document.getElementById('seedUrl').value = '';
|
|
toast(`${s.title} 을(를) 시드로 등록했습니다`);
|
|
await loadSeeds();
|
|
} catch(e){
|
|
help.textContent = '등록 실패: ' + e.message;
|
|
help.style.color = 'var(--danger)';
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function removeSeed(id, title){
|
|
if(!confirm(`"${title}" 시드를 해제할까요?\n이 채널에서 수집한 피드 영상도 함께 삭제됩니다.`)) return;
|
|
try {
|
|
await api(`${API}/seeds/${id}`, { method:'DELETE' });
|
|
toast('시드를 해제했습니다');
|
|
await loadSeeds();
|
|
await loadPrograms();
|
|
await loadFeed();
|
|
} catch(e){ toast('해제 실패: ' + e.message, true); }
|
|
}
|
|
|
|
async function resetSeed(id){
|
|
try {
|
|
await api(`${API}/seeds/${id}/reset-failure`, { method:'POST' });
|
|
toast('다시 활성화했습니다');
|
|
await loadSeeds();
|
|
} catch(e){ toast('실패: ' + e.message, true); }
|
|
}
|
|
|
|
async function suggestSeeds(){
|
|
// 내 채널이 등록돼 있으면 해시태그를 역분석하고, 없으면 키워드를 직접 받는다.
|
|
let detected = {};
|
|
try { detected = await api(API + '/seeds/keywords') || {}; } catch(e){ detected = {}; }
|
|
const tags = Object.keys(detected);
|
|
|
|
let keywords = null;
|
|
if(tags.length > 0){
|
|
const preview = tags.slice(0, 8).map(t => `#${t}(${detected[t]})`).join(' ');
|
|
if(!confirm(`내 채널에서 찾은 키워드로 소재 원본 채널을 검색합니다.\n\n${preview}\n\n키워드 1개당 쿼터 100 units. 진행할까요?`)) return;
|
|
} else {
|
|
const input = prompt('내 채널이 등록돼 있지 않아 자동 역분석을 못 합니다.\n프로그램 이름을 쉼표로 구분해 입력하세요. (키워드당 쿼터 100 units)',
|
|
'유퀴즈, 핑계고, 살롱드립');
|
|
if(input == null) return;
|
|
keywords = input.split(',').map(s => s.trim()).filter(Boolean);
|
|
if(keywords.length === 0) return;
|
|
}
|
|
|
|
try {
|
|
const r = await api(API + '/seeds/suggest', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify(keywords ? { keywords } : {})
|
|
});
|
|
toast(`후보 ${r.saved}개를 추천 채널에 담았습니다 — 추천 채널 화면에서 승인하세요`);
|
|
} catch(e){ toast('발굴 실패: ' + e.message, true); }
|
|
}
|
|
|
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeSeeds(); });
|
|
|
|
// ---------- init ----------
|
|
(async () => {
|
|
const preselect = restoreFilters();
|
|
await loadPrograms(preselect);
|
|
await loadFeed();
|
|
})();
|
|
/*]]>*/
|
|
</script>
|
|
</div>
|
|
</body>
|
|
|
|
</html>
|