h-lab/src/main/resources/templates/feed.html
hehihoho3@gmail.com 32540d1ceb fix: 내 채널이 없을 때 후보 자동 발굴이 조용히 0건으로 끝나던 문제
내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어가서 등록을
취소했다. 그러면 해시태그 역분석의 원천이 사라지는데, 기존 코드는 키워드
0개로 검색 루프를 그냥 빠져나와 "발굴했지만 아무것도 못 찾음"처럼 보였다.

- 역분석할 해시태그가 없으면 이유를 명시한 오류를 던진다.
- 피드 화면의 '후보 자동 발굴'은 먼저 감지된 키워드를 보여주고 확인을 받는다.
  키워드가 없으면 프로그램 이름을 직접 입력받아 검색한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:41:59 +09:00

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="닫기">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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>