피드 수집이 역할(SOURCE/RIVAL)과 무관하게 롱폼만 저장하도록 변경. 쇼츠는 이미 잘린 결과물이라 자를 원본(소재)이 아니므로 수집 대상에서 제외. UI 문구(경쟁 쇼츠 → 경쟁 채널)와 길이 필터도 함께 정리. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1030 lines
56 KiB
HTML
1030 lines
56 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="openPersons()">
|
|
<i data-lucide="user-search" style="width:15px;"></i> 인물 관리
|
|
</button>
|
|
<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>
|
|
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON"
|
|
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('PERSON')">
|
|
<i data-lucide="user-search" 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" id="programField">프로그램
|
|
<select id="fProgram" onchange="applyFilters()">
|
|
<option value="">전체</option>
|
|
</select>
|
|
</label>
|
|
<label class="field" for="fPerson" id="personField" style="display:none;">인물
|
|
<select id="fPerson" 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="personModal" class="modal-overlay" onclick="if(event.target===this) closePersons()">
|
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="personModalTitle">
|
|
<div class="modal-head">
|
|
<h3 id="personModalTitle">인물 관리</h3>
|
|
<button class="modal-close" onclick="closePersons()" aria-label="닫기">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
|
|
인물은 프로그램을 옮겨 다닙니다. 채널 시드로는 못 잡는 등장분을 이름으로 검색해 찾습니다.
|
|
<b>1명당 검색 101 units</b> 라 하루 1회만 돌고, 활성 인물에 상한이 있습니다.
|
|
</p>
|
|
|
|
<div class="seed-add">
|
|
<label class="sr-only" for="personName">인물 이름</label>
|
|
<input id="personName" type="text" placeholder="예: 윤경호">
|
|
<button class="btn btn-primary" id="personAddBtn" onclick="addPerson()">추가</button>
|
|
</div>
|
|
<p class="text-xs text-muted mt-2" id="personAddHelp">
|
|
내 채널 성과가 좋은 인물부터 넣으세요.
|
|
</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;" id="personCollectBtn"
|
|
onclick="collectPersons()" title="활성 인물 전체를 지금 수집합니다">
|
|
<i data-lucide="search" style="width:14px;"></i> 지금 수집
|
|
</button>
|
|
</div>
|
|
<div id="personList" class="seed-list"><div class="text-sm text-muted p-4">로딩 중...</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 영상 미리보기 -->
|
|
<div id="videoModal" class="modal-overlay" onclick="if(event.target===this) closeVideo()">
|
|
<div class="modal-card modal-wide" role="dialog" aria-modal="true" aria-labelledby="vmTitle">
|
|
<div class="modal-head">
|
|
<h3 id="vmTitle" class="truncate" style="padding-right:0.5rem;">영상</h3>
|
|
<button class="modal-close" id="vmClose" onclick="closeVideo()" aria-label="닫기">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="vmFrame" class="vm-frame"></div>
|
|
<p class="text-xs text-muted mt-2" id="vmMeta"></p>
|
|
<div class="vm-actions" id="vmActions"></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;
|
|
width:100%; padding:0; border:0; cursor:pointer;
|
|
}
|
|
.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; }
|
|
/* 외부 재생 표시 — 눌렀을 때 YouTube 로 나간다는 예고 */
|
|
.fext {
|
|
position:absolute; right:6px; top:6px;
|
|
display:inline-flex; align-items:center; justify-content:center;
|
|
width:22px; height:22px; border-radius:5px;
|
|
background:rgba(0,0,0,.72); color:#fff;
|
|
}
|
|
.fext svg { width:12px; height:12px; }
|
|
|
|
.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;
|
|
width:100%; padding:0; border:0; background:none; text-align:left;
|
|
font-family:inherit; cursor:pointer;
|
|
}
|
|
.ftitle:hover { text-decoration:underline; }
|
|
.ftitle:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:2px; }
|
|
.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;
|
|
}
|
|
|
|
/* ===== 영상 미리보기 ===== */
|
|
.modal-wide { max-width:900px; }
|
|
.vm-frame {
|
|
width:100%; aspect-ratio:16/9; background:#000;
|
|
border-radius:var(--r-sm); overflow:hidden;
|
|
}
|
|
/* 쇼츠는 세로라 화면을 다 먹지 않게 높이를 묶는다 */
|
|
.vm-frame.portrait { aspect-ratio:9/16; max-height:70vh; width:auto; margin:0 auto; }
|
|
.vm-frame iframe { width:100%; height:100%; border:0; display:block; }
|
|
.vm-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.8rem; }
|
|
.vm-actions .btn { min-height:40px; }
|
|
|
|
@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';
|
|
const SF_API = '/api/shortform';
|
|
|
|
let currentTab = 'SOURCE';
|
|
let items = [];
|
|
/** 숏폼 큐에 이미 담긴 videoId — 중복 등록을 막고 카드에 표시한다. */
|
|
let queued = new Set();
|
|
|
|
// ---------- 공통 ----------
|
|
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);
|
|
}
|
|
|
|
// ---------- 탭 ----------
|
|
const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
|
|
|
|
// 탭별로 의미 있는 필터만 남긴다 (소스·경쟁은 롱폼 채널 기준, 인물은 채널이 아니라 사람 기준)
|
|
function applyTabChrome(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);
|
|
document.getElementById('lengthField').style.display = (tab === 'PERSON') ? 'none' : '';
|
|
document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : '';
|
|
document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none';
|
|
if(tab === 'PERSON') document.getElementById('fLength').value = '';
|
|
}
|
|
|
|
function switchTab(tab){
|
|
if(tab === currentTab) return;
|
|
currentTab = tab;
|
|
applyTabChrome(tab);
|
|
// 탭마다 필터 목록이 다르므로 선택을 비우고 다시 채운다
|
|
document.getElementById('fProgram').value = '';
|
|
document.getElementById('fPerson').value = '';
|
|
loadFilterOptions().then(applyFilters);
|
|
}
|
|
|
|
document.querySelector('.feed-tabs').addEventListener('keydown', e => {
|
|
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
e.preventDefault();
|
|
const i = TABS.indexOf(currentTab);
|
|
const next = TABS[(i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length];
|
|
switchTab(next);
|
|
document.getElementById('tab-' + next).focus();
|
|
});
|
|
|
|
// ---------- 필터 ----------
|
|
function readFilters(){
|
|
return {
|
|
tab: currentTab,
|
|
channelId: document.getElementById('fProgram').value,
|
|
person: document.getElementById('fPerson').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.tab === 'PERSON'){ if(f.person) q.set('person', f.person); }
|
|
else 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(TABS.includes(tab) && tab !== 'SOURCE') currentTab = tab;
|
|
applyTabChrome(currentTab);
|
|
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 { channelId: q.get('channelId'), person: q.get('person') };
|
|
}
|
|
|
|
// ---------- 필터 목록(프로그램 / 인물) ----------
|
|
async function loadFilterOptions(preselect){
|
|
if(currentTab === 'PERSON') return loadPersonOptions(preselect && preselect.person);
|
|
return loadPrograms(preselect && preselect.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 loadPersonOptions(preselect){
|
|
const sel = document.getElementById('fPerson');
|
|
const keep = preselect || sel.value;
|
|
let list = [];
|
|
try { list = await api(API + '/persons') || []; } catch(e){ list = []; }
|
|
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
|
|
`<option value="${esc(p.name)}">${esc(p.name)} (${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.tab === 'PERSON'){ if(f.person) p.set('person', f.person); }
|
|
else 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 === 'RIVAL'){
|
|
if(it.rising) out.push('<span class="badge badge-warning"><i data-lucide="trending-up" style="width:11px;"></i>떡상중</span>');
|
|
} else {
|
|
// 인물 탭에서는 누구 때문에 걸렸는지가 제일 중요한 정보다
|
|
if(currentTab === 'PERSON' && it.matchedPerson)
|
|
out.push(`<span class="badge badge-primary">${esc(it.matchedPerson)}</span>`);
|
|
if(it.lengthBucket === 'CLIP') out.push('<span class="badge badge-muted">공식클립</span>');
|
|
else if(it.lengthBucket === 'FULL') out.push('<span class="badge badge-muted">풀에피</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 inQueue = queued.has(it.videoId);
|
|
const primary = currentTab === 'RIVAL'
|
|
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
|
|
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
|
|
: (inQueue
|
|
? `<a class="btn btn-secondary grow" href="/shortform">
|
|
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
|
|
: `<button class="btn btn-primary grow" id="sfBtn-${it.id}"
|
|
onclick="sendToQueue(${it.id})">
|
|
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
|
|
|
|
// 임베드 차단 영상은 눌렀을 때 YouTube 로 나가므로 미리 알려준다
|
|
const blocked = it.embeddable === false;
|
|
const playHint = blocked
|
|
? `<span class="fext" title="이 채널은 외부 재생을 막아둬서 YouTube 로 열립니다">
|
|
<i data-lucide="external-link"></i></span>`
|
|
: '';
|
|
|
|
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
|
|
<button class="fthumb" type="button" onclick="openVideo(${it.id})"
|
|
aria-label="${esc(it.title)} — ${blocked ? 'YouTube 에서 열기' : '여기서 재생'}">
|
|
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
|
|
${golden}
|
|
${playHint}
|
|
${dur ? `<span class="fdur">${dur}</span>` : ''}
|
|
</button>
|
|
<div class="fbody">
|
|
<div class="fmeta">
|
|
<span class="fprog">${esc(it.channelTitle || '-')}</span>
|
|
<span>·</span>
|
|
<span>${fmtAgo(it.publishedAt)}</span>
|
|
</div>
|
|
<button class="ftitle" type="button" onclick="openVideo(${it.id})">${esc(it.title)}</button>
|
|
<div class="fstats">
|
|
<span class="num">${fmtNum(it.viewCount)}회</span>${vph}
|
|
${badgeHtml(it)}
|
|
</div>
|
|
</div>
|
|
<div class="factions">
|
|
${primary}
|
|
${currentTab === 'RIVAL' ? '' : `
|
|
<a class="icon-btn" href="/rework/${it.id}" title="재가공 스튜디오"
|
|
aria-label="재가공 스튜디오에서 열기">
|
|
<i data-lucide="wand-2" style="width:16px;"></i>
|
|
</a>`}
|
|
<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){
|
|
if(currentTab === 'PERSON'){
|
|
showState('user-search', '추적 중인 인물이 없습니다',
|
|
'인물은 프로그램을 옮겨 다녀서 채널 시드로는 못 잡습니다. 이름을 등록하면 등장한 롱폼을 찾아옵니다.',
|
|
`<button class="btn btn-primary" onclick="openPersons()">인물 등록하기</button>`);
|
|
} else if(currentTab === 'SOURCE'){
|
|
showState('inbox', '아직 소재가 없습니다',
|
|
'소재 원본 채널(유퀴즈·핑계고 같은 웹예능 공식채널)을 시드로 등록한 뒤 수집하세요.',
|
|
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
|
|
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
|
|
} else {
|
|
showState('inbox', '아직 소재가 없습니다',
|
|
'경쟁 예능짤 채널을 시드로 등록한 뒤 수집하세요. (롱폼만 수집됩니다)',
|
|
`<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();
|
|
}
|
|
|
|
// ---------- 영상 미리보기 ----------
|
|
let lastFocused = null;
|
|
|
|
function openVideo(id){
|
|
const it = items.find(v => v.id === id);
|
|
if(!it) return;
|
|
|
|
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
|
|
|
|
// 임베드를 막아둔 채널이 많다(실측 9건 중 4건). 깨진 플레이어를 보여주느니
|
|
// 바로 YouTube 로 보낸다. embeddable 이 null 이면 아직 모르니 일단 시도한다.
|
|
if(it.embeddable === false){
|
|
window.open(url, '_blank', 'noopener');
|
|
return;
|
|
}
|
|
lastFocused = document.activeElement;
|
|
document.getElementById('vmTitle').textContent = it.title;
|
|
|
|
// 쇼츠는 세로, 나머지는 가로
|
|
const frame = document.getElementById('vmFrame');
|
|
frame.classList.toggle('portrait', it.lengthBucket === 'SHORTS');
|
|
frame.innerHTML = `<iframe src="https://www.youtube.com/embed/${encodeURIComponent(it.videoId)}?autoplay=1&rel=0"
|
|
title="${esc(it.title)}" allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
|
allowfullscreen></iframe>`;
|
|
|
|
const dur = fmtDur(it.durationSec);
|
|
document.getElementById('vmMeta').textContent =
|
|
`${it.channelTitle || '-'} · ${fmtAgo(it.publishedAt)} · ${fmtNum(it.viewCount)}회${dur ? ' · ' + dur : ''}`;
|
|
|
|
// 임베드가 막힌 영상이 있어 YouTube 로 여는 길을 항상 열어둔다
|
|
const inQueue = queued.has(it.videoId);
|
|
const queueBtn = currentTab === 'RIVAL' ? '' : (inQueue
|
|
? `<a class="btn btn-secondary" href="/shortform">
|
|
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
|
|
: `<button class="btn btn-primary" onclick="sendToQueue(${it.id}); closeVideo();">
|
|
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
|
|
document.getElementById('vmActions').innerHTML = `
|
|
${queueBtn}
|
|
<a class="btn btn-secondary" href="${url}" target="_blank" rel="noopener">
|
|
<i data-lucide="external-link" style="width:14px;"></i> YouTube 에서 열기</a>`;
|
|
|
|
document.getElementById('videoModal').classList.add('open');
|
|
if(window.lucide) lucide.createIcons();
|
|
document.getElementById('vmClose').focus();
|
|
}
|
|
|
|
function closeVideo(){
|
|
const modal = document.getElementById('videoModal');
|
|
if(!modal.classList.contains('open')) return;
|
|
modal.classList.remove('open');
|
|
document.getElementById('vmFrame').innerHTML = ''; // 재생 중지
|
|
if(lastFocused && lastFocused.focus) lastFocused.focus();
|
|
lastFocused = null;
|
|
}
|
|
|
|
// ---------- 숏폼 큐 ----------
|
|
/** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */
|
|
async function loadQueued(){
|
|
try {
|
|
const jobs = await api(SF_API + '/jobs') || [];
|
|
queued = new Set(jobs.map(j => j.videoId).filter(Boolean));
|
|
} catch(e){ /* 큐를 못 읽어도 피드는 정상 동작 */ }
|
|
}
|
|
|
|
async function sendToQueue(id){
|
|
const it = items.find(v => v.id === id);
|
|
if(!it) return;
|
|
const btn = document.getElementById('sfBtn-' + id);
|
|
if(btn){
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 보내는 중...';
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
try {
|
|
await api(SF_API + '/jobs', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId })
|
|
});
|
|
queued.add(it.videoId);
|
|
render();
|
|
toast('숏폼 큐에 담았습니다 — 숏폼 큐에서 Opal 실행하세요');
|
|
} catch(e){
|
|
toast('숏폼 큐 등록 실패: ' + e.message, true);
|
|
render();
|
|
}
|
|
}
|
|
|
|
// ---------- 카드 액션 ----------
|
|
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); }
|
|
}
|
|
|
|
// ---------- 인물 관리 ----------
|
|
function openPersons(){
|
|
document.getElementById('personModal').classList.add('open');
|
|
loadPersonList();
|
|
setTimeout(()=> document.getElementById('personName').focus(), 50);
|
|
}
|
|
function closePersons(){ document.getElementById('personModal').classList.remove('open'); }
|
|
|
|
async function loadPersonList(){
|
|
const box = document.getElementById('personList');
|
|
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
|
|
let list = [];
|
|
try { list = await api(API + '/persons') || []; }
|
|
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">추적 중인 인물이 없습니다. 위에서 이름을 추가하세요.</div>';
|
|
return;
|
|
}
|
|
box.innerHTML = list.map(p => {
|
|
const last = p.lastCollectedAt
|
|
? `${fmtAgo(p.lastCollectedAt)} 수집 · ${p.lastFound ?? 0}건`
|
|
: '아직 수집 전';
|
|
return `<div class="seed-row">
|
|
<div class="sname">
|
|
<b>${esc(p.name)}</b>
|
|
<span>${p.videoCount}건 담김 · ${last}</span>
|
|
</div>
|
|
<span class="badge ${p.enabled ? 'badge-primary' : 'badge-muted'}">${p.enabled ? '추적중' : '중지'}</span>
|
|
<button class="warn" title="${p.enabled ? '추적 중지' : '추적 재개'}"
|
|
aria-label="${p.enabled ? '추적 중지' : '추적 재개'}"
|
|
onclick="togglePerson(${p.id}, ${!p.enabled})">
|
|
<i data-lucide="${p.enabled ? 'pause' : 'play'}" style="width:15px;"></i></button>
|
|
<button title="추적 해제" aria-label="추적 해제" onclick="removePerson(${p.id}, '${esc(p.name)}')">
|
|
<i data-lucide="trash-2" style="width:15px;"></i></button>
|
|
</div>`;
|
|
}).join('');
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
async function addPerson(){
|
|
const name = document.getElementById('personName').value.trim();
|
|
const help = document.getElementById('personAddHelp');
|
|
if(!name){
|
|
help.textContent = '인물 이름을 입력하세요.';
|
|
help.style.color = 'var(--danger)';
|
|
document.getElementById('personName').focus();
|
|
return;
|
|
}
|
|
help.textContent = '내 채널 성과가 좋은 인물부터 넣으세요.';
|
|
help.style.color = '';
|
|
|
|
const btn = document.getElementById('personAddBtn');
|
|
btn.disabled = true;
|
|
try {
|
|
await api(API + '/persons', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ name })
|
|
});
|
|
document.getElementById('personName').value = '';
|
|
toast(`${name} 추적을 시작합니다`);
|
|
await loadPersonList();
|
|
} catch(e){
|
|
help.textContent = e.message;
|
|
help.style.color = 'var(--danger)';
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function togglePerson(id, enabled){
|
|
try {
|
|
await api(`${API}/persons/${id}/toggle?enabled=${enabled}`, { method:'POST' });
|
|
await loadPersonList();
|
|
} catch(e){ toast(e.message, true); }
|
|
}
|
|
|
|
async function removePerson(id, name){
|
|
if(!confirm(`"${name}" 추적을 해제할까요?\n이미 담긴 영상은 그대로 남습니다.`)) return;
|
|
try {
|
|
await api(`${API}/persons/${id}`, { method:'DELETE' });
|
|
toast('추적을 해제했습니다');
|
|
await loadPersonList();
|
|
} catch(e){ toast('해제 실패: ' + e.message, true); }
|
|
}
|
|
|
|
async function collectPersons(){
|
|
if(!confirm('활성 인물 전체를 지금 수집합니다.\n1명당 YouTube 쿼터 101 units 를 씁니다. 진행할까요?')) return;
|
|
const btn = document.getElementById('personCollectBtn');
|
|
btn.disabled = true;
|
|
const orig = btn.innerHTML;
|
|
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 수집 중...';
|
|
if(window.lucide) lucide.createIcons();
|
|
try {
|
|
const r = await api(API + '/persons/collect', { method:'POST' });
|
|
toast(`인물 ${r.searched}명에서 롱폼 ${r.savedVideos}건을 담았습니다`);
|
|
await loadPersonList();
|
|
if(currentTab === 'PERSON'){ await loadPersonOptions(); await loadFeed(); }
|
|
} catch(e){
|
|
toast('수집 실패: ' + e.message, true);
|
|
} finally {
|
|
btn.disabled = false; btn.innerHTML = orig;
|
|
if(window.lucide) lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if(e.key === 'Escape'){ closeVideo(); closeSeeds(); closePersons(); }
|
|
});
|
|
|
|
// ---------- init ----------
|
|
(async () => {
|
|
const preselect = restoreFilters();
|
|
await Promise.all([loadFilterOptions(preselect), loadQueued()]);
|
|
await loadFeed();
|
|
})();
|
|
/*]]>*/
|
|
</script>
|
|
</div>
|
|
</body>
|
|
|
|
</html>
|