feat(channels): 채널 영상 모아보기 카드형 재디자인 + 한국어 + 정렬 드롭다운
테이블→카드 그리드(썸네일·배율 배지·조회수·구독자·날짜), 한국어 라벨, 정렬 드롭다운 (배율/조회수/최신/구독자), 미리보기 모달(9:16), 선택/전체 담기·다운로드 기능 보존. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f3c6d20442
commit
fec4c77cb9
@ -3,86 +3,84 @@
|
|||||||
layout:decorate="~{layout/base}">
|
layout:decorate="~{layout/base}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>h-lab - Multi-Channel Videos</title>
|
<title>h-lab - 채널 영상 모아보기</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div layout:fragment="content">
|
<div layout:fragment="content">
|
||||||
<header class="mb-6">
|
<div class="page-header">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div>
|
||||||
<a th:href="@{/channels}" class="text-muted hover:text-white transition-colors">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<i data-lucide="arrow-left" style="width: 20px;"></i>
|
<a th:href="@{/channels}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a>
|
||||||
</a>
|
<h1>채널 영상 모아보기</h1>
|
||||||
<h1 class="text-2xl font-bold">Search within Selected Channels</h1>
|
</div>
|
||||||
|
<p class="sub">선택한 <span th:text="${channels != null ? #lists.size(channels) : 0}">0</span>개 채널의 영상을 한 번에 검색·정렬하고 수집함에 담습니다.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-6 mb-6">
|
<!-- 채널 칩 -->
|
||||||
<div class="text-sm font-bold text-muted mb-4">Tracking <span th:text="${channels != null ? #lists.size(channels) : 0}">0</span> Channels</div>
|
<div class="card mb-4" th:if="${channels != null}">
|
||||||
<div class="flex flex-wrap gap-4" th:if="${channels != null}">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div th:each="channel : ${channels}" class="flex items-center gap-2 p-2 rounded-lg bg-[var(--surface-2)] border border-[var(--glass-border)]">
|
<div th:each="channel : ${channels}" class="flex items-center gap-2" style="padding:5px 10px; border-radius:999px; background:var(--surface-2); border:1px solid var(--glass-border);">
|
||||||
<img th:src="${channel.thumbnailUrl}" alt="Thumbnail"
|
<img th:src="${channel.thumbnailUrl}" alt="" th:if="${channel.thumbnailUrl != null}" style="width:24px; height:24px; border-radius:50%; object-fit:cover;">
|
||||||
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;" th:if="${channel.thumbnailUrl != null}">
|
<span class="text-sm" th:text="${channel.title}">채널</span>
|
||||||
<span class="text-sm font-medium" th:text="${channel.title}">Channel Name</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Search / Filter Area (Copied from videos.html) -->
|
<!-- 검색 조건 -->
|
||||||
<div class="card mb-6">
|
<div class="card mb-4">
|
||||||
<form id="searchForm" onsubmit="event.preventDefault(); executeSearch();">
|
<form id="searchForm" onsubmit="event.preventDefault(); executeSearch();">
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end;">
|
<div style="display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end;">
|
||||||
<!-- Period -->
|
<div style="display:flex; flex-direction:column; gap:6px; min-width:150px;">
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
|
<label class="text-sm text-muted">기간</label>
|
||||||
<label class="text-sm font-bold text-muted">Period</label>
|
<select id="periodDays" class="pub-in" style="height:42px;">
|
||||||
<select id="periodDays" class="w-full"
|
<option value="1">최근 1일</option>
|
||||||
style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
|
<option value="7" selected>최근 7일</option>
|
||||||
<option value="1" style="background: var(--surface); color: var(--text);">Within 1 Day</option>
|
<option value="10">최근 10일</option>
|
||||||
<option value="7" style="background: var(--surface); color: var(--text);" selected>Within 7 Days</option>
|
<option value="15">최근 15일</option>
|
||||||
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option>
|
<option value="30">최근 30일</option>
|
||||||
<option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</option>
|
<option value="0">전체</option>
|
||||||
<option value="30" style="background: var(--surface); color: var(--text);">Within 30 Days</option>
|
|
||||||
<option value="0" style="background: var(--surface); color: var(--text);">전부 (All)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:6px; min-width:160px;">
|
||||||
<!-- Format -->
|
<label class="text-sm text-muted">형식</label>
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
|
<div class="flex gap-4" style="padding:0 10px; height:42px; align-items:center; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md);">
|
||||||
<label class="text-sm font-bold text-muted">Format</label>
|
<label class="flex items-center gap-1 cursor-pointer"><input type="radio" name="format" value="SHORTS" checked> Shorts</label>
|
||||||
<div class="flex gap-4 w-full" style="padding: 8px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
|
<label class="flex items-center gap-1 cursor-pointer"><input type="radio" name="format" value="LONG_FORM"> 롱폼</label>
|
||||||
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
|
|
||||||
<input type="radio" name="format" value="SHORTS" checked> Shorts
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
|
|
||||||
<input type="radio" name="format" value="LONG_FORM"> Long
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:6px; min-width:130px;">
|
||||||
<!-- Page Size -->
|
<label class="text-sm text-muted">불러올 수</label>
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;">
|
<select id="pageSize" class="pub-in" style="height:42px;">
|
||||||
<label class="text-sm font-bold text-muted">Load Size</label>
|
<option value="20">20개</option>
|
||||||
<select id="pageSize" class="w-full"
|
<option value="50" selected>50개</option>
|
||||||
style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
|
<option value="100">100개</option>
|
||||||
<option value="20" style="background: var(--surface); color: var(--text);">20 items</option>
|
|
||||||
<option value="50" style="background: var(--surface); color: var(--text);" selected>50 items</option>
|
|
||||||
<option value="100" style="background: var(--surface); color: var(--text);">100 items</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button type="submit" class="btn btn-primary px-6 py-2 flex items-center gap-1" id="searchBtn" style="height:42px;">
|
||||||
|
<i data-lucide="search" style="width:16px;"></i> 검색
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<button type="submit" class="btn btn-primary px-8 py-2 flex items-center justify-center" id="searchBtn">
|
|
||||||
<i data-lucide="search" style="width: 18px; margin-right: 6px;"></i> <span class="font-bold">Search within Channels</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 수집 툴바 -->
|
<!-- 정렬 + 담기 툴바 -->
|
||||||
<div class="flex items-center justify-between mb-3" style="gap:12px;">
|
<div class="flex items-center justify-between mb-3" style="gap:12px; flex-wrap:wrap;">
|
||||||
<span id="selInfo" class="text-sm text-muted"></span>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted">정렬</span>
|
||||||
|
<select id="sortSel" class="pub-in" style="width:auto; padding:7px 9px;" onchange="onSortChange()">
|
||||||
|
<option value="performance">배율순(떡상)</option>
|
||||||
|
<option value="views">조회수순</option>
|
||||||
|
<option value="date">최신순</option>
|
||||||
|
<option value="subs">구독자순</option>
|
||||||
|
</select>
|
||||||
|
<span id="selInfo" class="text-sm text-muted ml-2"></span>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<label class="flex items-center gap-1 text-sm text-muted cursor-pointer" style="user-select:none;">
|
||||||
|
<input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)"> 전체 선택
|
||||||
|
</label>
|
||||||
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="collectSelected()" id="collectSelBtn">
|
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="collectSelected()" id="collectSelBtn">
|
||||||
<i data-lucide="check-square" style="width:16px;"></i> 선택 담기
|
<i data-lucide="check-square" style="width:16px;"></i> 선택 담기
|
||||||
</button>
|
</button>
|
||||||
@ -92,346 +90,203 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video List Table -->
|
<!-- 결과 카드 그리드 -->
|
||||||
<div class="card p-0" style="overflow-x: auto;">
|
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:1rem;">
|
||||||
<table class="w-full rcards" style="border-collapse: collapse; text-align: left;">
|
<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">검색 조건을 정하고 ‘검색’을 누르세요.</div>
|
||||||
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
|
|
||||||
<tr>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)" title="전체 선택"></th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted">Thumbnail</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted">Title</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted">Channel</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('date')" style="user-select: none;">
|
|
||||||
Publish Date <span id="sort-icon-date" style="display: inline-block; width: 14px;"></span>
|
|
||||||
</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('performance')" style="user-select: none;">
|
|
||||||
Performance <span id="sort-icon-performance" style="display: inline-block; width: 14px;">▼</span>
|
|
||||||
</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('views')" style="user-select: none;">
|
|
||||||
Views <span id="sort-icon-views" style="display: inline-block; width: 14px;"></span>
|
|
||||||
</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('subs')" style="user-select: none;">
|
|
||||||
Subscribers <span id="sort-icon-subs" style="display: inline-block; width: 14px;"></span>
|
|
||||||
</th>
|
|
||||||
<th class="p-4 text-sm font-bold text-muted">Download</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="resultBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" class="p-8 text-center text-muted">Select search conditions and click Search to find videos from these channels.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-center">
|
<div class="mt-4 flex justify-center">
|
||||||
<button id="loadMoreBtn" class="btn btn-secondary w-full p-3" style="display: none;" onclick="handleLoadMore()">Load More</button>
|
<button id="loadMoreBtn" class="btn btn-secondary px-6 py-3" style="display:none;" onclick="handleLoadMore()">더 보기</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Player Modal -->
|
<!-- 미리보기 모달 -->
|
||||||
<div id="videoModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.85); align-items: center; justify-content: center;" onclick="if(event.target === this) closeVideoModal()">
|
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
|
||||||
<div style="position: relative; width: 95%; max-width: 1200px; background-color: var(--glass-bg, var(--surface)); border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid var(--glass-border);">
|
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video Player</h3>
|
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">영상</h3>
|
||||||
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">×</button>
|
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="iframeContainer" style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; background: #000;"></div>
|
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pub-in { width:100%; padding:9px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem; }
|
||||||
|
.pub-in option { background:var(--surface); color:var(--text); }
|
||||||
|
.vc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; transition:border-color .15s; }
|
||||||
|
.vc-card.sel { border-color:var(--primary); }
|
||||||
|
.vc-thumb-wrap { position:relative; cursor:pointer; }
|
||||||
|
.vc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; display:block; }
|
||||||
|
.vc-play { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.55); border-radius:50%; width:38px; height:38px; display:flex; align-items:center; justify-content:center; }
|
||||||
|
.vc-chk { position:absolute; top:8px; left:8px; width:20px; height:20px; cursor:pointer; z-index:2; }
|
||||||
|
.vc-body { padding:11px; }
|
||||||
|
.vc-title { font-weight:700; font-size:0.9rem; line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||||
|
.vc-ratio { display:inline-block; color:#fff; font-weight:800; font-size:0.78rem; padding:2px 7px; border-radius:999px; }
|
||||||
|
.vc-actions { display:flex; gap:6px; margin-top:9px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
const selectedYoutubeChannelIds = /*[[${selectedYoutubeIds}]]*/ [];
|
const selectedYoutubeChannelIds = /*[[${selectedYoutubeIds}]]*/ [];
|
||||||
/*]]>*/
|
|
||||||
|
|
||||||
let allLoadedVideos = [];
|
let allLoadedVideos = [];
|
||||||
let currentTokens = {};
|
let currentTokens = {};
|
||||||
let displayedCount = 0;
|
let displayedCount = 0;
|
||||||
let currentCondition = {};
|
let currentCondition = {};
|
||||||
|
let currentSort = 'performance';
|
||||||
|
|
||||||
let currentSortColumn = 'performance';
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
let currentSortOrder = 'desc';
|
function fmt(n){ return (n==null)?'0':Number(n).toLocaleString(); }
|
||||||
|
|
||||||
function handleSort(column) {
|
|
||||||
if (currentSortColumn === column) {
|
|
||||||
currentSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
currentSortColumn = column;
|
|
||||||
currentSortOrder = 'desc';
|
|
||||||
}
|
|
||||||
updateSortIcons();
|
|
||||||
|
|
||||||
|
function onSortChange(){
|
||||||
|
currentSort = document.getElementById('sortSel').value;
|
||||||
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
||||||
const targetLimit = displayedCount > 0 ? displayedCount : size;
|
renderGrid(displayedCount > 0 ? displayedCount : size);
|
||||||
renderTable(targetLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSortIcons() {
|
|
||||||
['date', 'performance', 'views', 'subs'].forEach(col => {
|
|
||||||
const iconSpan = document.getElementById('sort-icon-' + col);
|
|
||||||
if (iconSpan) {
|
|
||||||
if (currentSortColumn === col) {
|
|
||||||
iconSpan.innerText = currentSortOrder === 'asc' ? '▲' : '▼';
|
|
||||||
} else {
|
|
||||||
iconSpan.innerText = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeSearch(){
|
function executeSearch(){
|
||||||
const keyword = ''; // Removed input field, default to empty
|
|
||||||
const periodDays = parseInt(document.getElementById('periodDays').value);
|
const periodDays = parseInt(document.getElementById('periodDays').value);
|
||||||
const formatRadios = document.querySelectorAll('input[name="format"]:checked');
|
const formatRadios = document.querySelectorAll('input[name="format"]:checked');
|
||||||
const format = formatRadios.length > 0 ? formatRadios[0].value : 'SHORTS';
|
const format = formatRadios.length > 0 ? formatRadios[0].value : 'SHORTS';
|
||||||
|
currentCondition = { keyword:'', channelIds:selectedYoutubeChannelIds, periodDays:periodDays, format:format, pageTokens:{} };
|
||||||
currentCondition = {
|
allLoadedVideos = []; currentTokens = {}; displayedCount = 0;
|
||||||
keyword: keyword,
|
|
||||||
channelIds: selectedYoutubeChannelIds, // Use selected channels
|
|
||||||
periodDays: periodDays,
|
|
||||||
format: format,
|
|
||||||
pageTokens: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
allLoadedVideos = [];
|
|
||||||
currentTokens = {};
|
|
||||||
displayedCount = 0;
|
|
||||||
|
|
||||||
document.getElementById('resultBody').innerHTML = '';
|
|
||||||
document.getElementById('loadMoreBtn').style.display = 'none';
|
document.getElementById('loadMoreBtn').style.display = 'none';
|
||||||
|
|
||||||
fetchMore(true);
|
fetchMore(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMore(isFirst){
|
async function fetchMore(isFirst){
|
||||||
const searchBtn = document.getElementById('searchBtn');
|
const searchBtn = document.getElementById('searchBtn');
|
||||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||||
const resultBody = document.getElementById('resultBody');
|
const grid = document.getElementById('grid');
|
||||||
|
|
||||||
if (isFirst){
|
if (isFirst){
|
||||||
searchBtn.disabled = true;
|
searchBtn.disabled = true; searchBtn.innerHTML = '<span class="text-sm">검색 중…</span>';
|
||||||
searchBtn.innerHTML = '<span class="text-sm">Searching...</span>';
|
grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">'+selectedYoutubeChannelIds.length+'개 채널에서 불러오는 중… 잠시 걸립니다.</div>';
|
||||||
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-muted">Loading results from ' + selectedYoutubeChannelIds.length + ' channels... This may take a moment.</td></tr>';
|
} else { loadMoreBtn.disabled = true; loadMoreBtn.innerHTML = '불러오는 중…'; }
|
||||||
} else {
|
|
||||||
loadMoreBtn.disabled = true;
|
|
||||||
loadMoreBtn.innerHTML = 'Loading...';
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCondition.pageTokens = currentTokens;
|
currentCondition.pageTokens = currentTokens;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/youtube/search', {
|
const response = await fetch('/api/youtube/search', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(currentCondition) });
|
||||||
method: 'POST',
|
if (!response.ok) throw new Error('검색 API 실패');
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(currentCondition)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('API request failed');
|
|
||||||
|
|
||||||
const pageDto = await response.json();
|
const pageDto = await response.json();
|
||||||
|
|
||||||
if (isFirst) {
|
|
||||||
resultBody.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
allLoadedVideos = allLoadedVideos.concat(pageDto.items || []);
|
allLoadedVideos = allLoadedVideos.concat(pageDto.items || []);
|
||||||
currentTokens = pageDto.nextTokens || {};
|
currentTokens = pageDto.nextTokens || {};
|
||||||
|
|
||||||
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
||||||
const targetLimit = isFirst ? size : displayedCount + size;
|
renderGrid(isFirst ? size : displayedCount + size);
|
||||||
renderTable(targetLimit);
|
|
||||||
} catch(error){
|
} catch(error){
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
if (isFirst) {
|
if (isFirst) grid.innerHTML = '<div style="grid-column:1/-1; padding:2rem; text-align:center; color:#f87171;">검색 중 오류가 발생했습니다.</div>';
|
||||||
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-danger">Error occurred during search.</td></tr>';
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (isFirst) {
|
if (isFirst){ searchBtn.disabled = false; searchBtn.innerHTML = '<i data-lucide="search" style="width:16px;"></i> 검색'; if(window.lucide) lucide.createIcons(); }
|
||||||
searchBtn.disabled = false;
|
|
||||||
searchBtn.innerHTML = '<i data-lucide="search" style="width: 18px; margin-right: 6px;"></i> Search within Channels';
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadMore(){
|
function handleLoadMore(){
|
||||||
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
||||||
if (displayedCount < allLoadedVideos.length) {
|
if (displayedCount < allLoadedVideos.length) renderGrid(displayedCount + size);
|
||||||
renderTable(displayedCount + size);
|
else fetchMore(false);
|
||||||
} else {
|
|
||||||
fetchMore(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(limit) {
|
function ratioOf(v){
|
||||||
const resultBody = document.getElementById('resultBody');
|
if (v.viewCount != null && v.subscriberCount != null && v.subscriberCount > 0) return v.viewCount / v.subscriberCount;
|
||||||
|
return (v.viewCount > 0) ? Infinity : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(limit){
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
if (allLoadedVideos.length === 0){
|
if (allLoadedVideos.length === 0){
|
||||||
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-muted">No results found within selected channels.</td></tr>';
|
grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">선택한 채널에서 결과를 찾지 못했습니다.</div>';
|
||||||
document.getElementById('loadMoreBtn').style.display = 'none';
|
document.getElementById('loadMoreBtn').style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
allLoadedVideos.sort((a,b)=>{
|
allLoadedVideos.sort((a,b)=>{
|
||||||
let valA = 0, valB = 0;
|
let va=0, vb=0;
|
||||||
if (currentSortColumn === 'views') {
|
if (currentSort==='views'){ va=a.viewCount||0; vb=b.viewCount||0; }
|
||||||
valA = a.viewCount || 0;
|
else if (currentSort==='subs'){ va=a.subscriberCount||0; vb=b.subscriberCount||0; }
|
||||||
valB = b.viewCount || 0;
|
else if (currentSort==='date'){ va=new Date(a.publishedAt).getTime(); vb=new Date(b.publishedAt).getTime(); }
|
||||||
} else if (currentSortColumn === 'subs') {
|
else { va=ratioOf(a); vb=ratioOf(b); }
|
||||||
valA = a.subscriberCount || 0;
|
return vb - va; // 내림차순
|
||||||
valB = b.subscriberCount || 0;
|
|
||||||
} else if (currentSortColumn === 'date') {
|
|
||||||
valA = new Date(a.publishedAt).getTime();
|
|
||||||
valB = new Date(b.publishedAt).getTime();
|
|
||||||
} else if (currentSortColumn === 'performance') {
|
|
||||||
valA = (a.subscriberCount > 0) ? (a.viewCount / a.subscriberCount) : ((a.viewCount > 0) ? Infinity : -1);
|
|
||||||
valB = (b.subscriberCount > 0) ? (b.viewCount / b.subscriberCount) : ((b.viewCount > 0) ? Infinity : -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valA < valB) return currentSortOrder === 'asc' ? -1 : 1;
|
|
||||||
if (valA > valB) return currentSortOrder === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
resultBody.innerHTML = '';
|
|
||||||
const actualLimit = Math.min(limit, allLoadedVideos.length);
|
const actualLimit = Math.min(limit, allLoadedVideos.length);
|
||||||
const videosToRender = allLoadedVideos.slice(0, actualLimit);
|
const list = allLoadedVideos.slice(0, actualLimit);
|
||||||
|
grid.innerHTML = list.map(v=>{
|
||||||
videosToRender.forEach(video => {
|
const d = new Date(v.publishedAt);
|
||||||
const tr = document.createElement('tr');
|
const dateStr = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
|
||||||
tr.style.borderBottom = '1px solid var(--glass-border)';
|
const r = ratioOf(v);
|
||||||
|
let ratioStr='-', ratioBg='#9ca3af';
|
||||||
const pubDate = new Date(video.publishedAt);
|
if (r >= 0 && r !== Infinity){ ratioStr=r.toFixed(1)+'x'; ratioBg = r>=10 ? '#ef4444' : (r>=2 ? '#f59e0b' : '#10b981'); }
|
||||||
let formattedDate = pubDate.getFullYear() + '-' + String(pubDate.getMonth() + 1).padStart(2, '0') + '-' + String(pubDate.getDate()).padStart(2, '0') + ' ' + String(pubDate.getHours()).padStart(2, '0') + ':' + String(pubDate.getMinutes()).padStart(2, '0');
|
else if (r === Infinity){ ratioStr='∞'; ratioBg='#ef4444'; }
|
||||||
|
const safeTitle = esc(v.title).replace(/"/g,'"');
|
||||||
const viewStr = video.viewCount != null ? video.viewCount.toLocaleString() : '0';
|
return '<div class="vc-card" id="vc-'+v.videoId+'">'
|
||||||
const subStr = video.subscriberCount != null ? video.subscriberCount.toLocaleString() : '0';
|
+ '<div class="vc-thumb-wrap">'
|
||||||
|
+ '<input type="checkbox" class="vc-chk rowChk" value="'+v.videoId+'" onclick="event.stopPropagation()" onchange="onRowCheck(this,\''+v.videoId+'\')">'
|
||||||
let perfStr = '-';
|
+ '<img class="vc-thumb" src="'+esc(v.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(v.videoId)+'" data-title="'+safeTitle+'" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">'
|
||||||
let perfStyle = 'color: var(--text-muted);';
|
+ '<div class="vc-play" data-vid="'+esc(v.videoId)+'" data-title="'+safeTitle+'" onclick="openVideoModal(this.dataset.vid, this.dataset.title)"><i data-lucide="play" style="width:16px; color:#fff;"></i></div>'
|
||||||
if (video.viewCount != null && video.subscriberCount != null && video.subscriberCount > 0) {
|
+ '</div>'
|
||||||
const ratio = (video.viewCount / video.subscriberCount) * 100;
|
+ '<div class="vc-body">'
|
||||||
perfStr = ratio.toFixed(1) + '%';
|
+ '<div class="vc-title"><a href="https://www.youtube.com/watch?v='+esc(v.videoId)+'" target="_blank" class="hover:underline" style="color:var(--text);">'+esc(v.title)+'</a></div>'
|
||||||
if (ratio >= 100) perfStyle = 'color: #ef4444; font-weight: bold;';
|
+ '<div class="text-sm text-muted mt-1" style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"><a href="https://www.youtube.com/channel/'+esc(v.channelId)+'" target="_blank" class="hover:underline">'+esc(v.channelTitle)+'</a></div>'
|
||||||
else if (ratio >= 30) perfStyle = 'color: #f59e0b; font-weight: bold;';
|
+ '<div class="mt-2 flex items-center gap-2" style="flex-wrap:wrap;">'
|
||||||
else perfStyle = 'color: #10b981; font-weight: bold;';
|
+ '<span class="vc-ratio" style="background:'+ratioBg+';">🔥 '+ratioStr+'</span>'
|
||||||
}
|
+ '<span class="text-sm text-muted">👁 '+fmt(v.viewCount)+'</span>'
|
||||||
|
+ '<span class="text-sm text-muted">👤 '+fmt(v.subscriberCount)+'</span>'
|
||||||
const safeTitle = video.title.replace(/"/g, """);
|
+ '</div>'
|
||||||
|
+ '<div class="text-xs text-muted mt-1">'+dateStr+'</div>'
|
||||||
tr.innerHTML =
|
+ '<div class="vc-actions">'
|
||||||
'<td class="p-4">' +
|
+ '<button class="btn btn-secondary px-3 py-2" style="font-size:0.8rem;" onclick="downloadVideo(\'https://www.youtube.com/watch?v='+v.videoId+'\')" title="고화질 다운로드"><i data-lucide="download" style="width:14px;"></i> 다운로드</button>'
|
||||||
'<input type="checkbox" class="rowChk" value="' + video.videoId + '" onchange="updateSelInfo()">' +
|
+ '<button class="btn btn-secondary px-3 py-2" style="font-size:0.8rem;" onclick="copyToClipboard(\'https://www.youtube.com/watch?v='+v.videoId+'\')" title="URL 복사"><i data-lucide="copy" style="width:14px;"></i></button>'
|
||||||
'</td>' +
|
+ '</div>'
|
||||||
'<td class="p-4">' +
|
+ '</div></div>';
|
||||||
'<div style="position: relative; display: inline-block; cursor: pointer;" data-videoid="' + video.videoId + '" data-videotitle="' + safeTitle + '" onclick="openVideoModal(this.dataset.videoid, this.dataset.videotitle)">' +
|
}).join('');
|
||||||
'<img src="' + video.thumbnailUrl + '" alt="Thumb" style="width: 120px; height: 68px; object-fit: cover; border-radius: 6px; transition: transform 0.2s;" onmouseover="this.style.transform=\'scale(1.05)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
|
|
||||||
'<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.6); border-radius: 50%; padding: 8px; display: flex; align-items: center; justify-content: center;">' +
|
|
||||||
'<i data-lucide="play" style="color: var(--text); width: 16px; height: 16px; fill: white;"></i>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</td>' +
|
|
||||||
'<td class="p-4">' +
|
|
||||||
'<div class="flex items-start justify-between gap-2" style="max-width: 300px;">' +
|
|
||||||
'<div class="font-bold text-sm" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1;">' +
|
|
||||||
'<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline">' + video.title + '</a>' +
|
|
||||||
'</div>' +
|
|
||||||
'<button onclick="copyToClipboard(\'https://www.youtube.com/watch?v=' + video.videoId + '\')" class="text-muted hover:text-white flex-shrink-0" title="URL 복사" style="background: none; border: none; cursor: pointer; padding: 2px;">' +
|
|
||||||
'<i data-lucide="copy" style="width: 14px; height: 14px;"></i>' +
|
|
||||||
'</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</td>' +
|
|
||||||
'<td class="p-4 text-sm text-muted">' +
|
|
||||||
'<div class="flex items-center gap-2">' +
|
|
||||||
'<a href="https://www.youtube.com/channel/' + video.channelId + '" target="_blank" class="hover:underline font-semibold" style="color: #e2e8f0;">' + video.channelTitle + '</a>' +
|
|
||||||
'</div>' +
|
|
||||||
'</td>' +
|
|
||||||
'<td class="p-4 text-sm text-muted">' + formattedDate + '</td>' +
|
|
||||||
'<td class="p-4 text-sm" style="' + perfStyle + '">' + perfStr + '</td>' +
|
|
||||||
'<td class="p-4 text-sm">' + viewStr + '</td>' +
|
|
||||||
'<td class="p-4 text-sm">' + subStr + '</td>' +
|
|
||||||
'<td class="p-4 text-sm">' +
|
|
||||||
'<button onclick="downloadVideo(\'https://www.youtube.com/watch?v=' + video.videoId + '\')" class="btn btn-secondary p-2 flex items-center justify-center" title="고화질 다운로드">' +
|
|
||||||
'<i data-lucide="download" style="width: 16px; height: 16px;"></i>' +
|
|
||||||
'</button>' +
|
|
||||||
'</td>';
|
|
||||||
resultBody.appendChild(tr);
|
|
||||||
});
|
|
||||||
|
|
||||||
displayedCount = actualLimit;
|
displayedCount = actualLimit;
|
||||||
updateLoadMoreButton();
|
updateLoadMoreButton();
|
||||||
const selectAll = document.getElementById('selectAll');
|
const selectAll = document.getElementById('selectAll'); if (selectAll) selectAll.checked = false;
|
||||||
if (selectAll) selectAll.checked = false;
|
|
||||||
updateSelInfo();
|
updateSelInfo();
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadVideo(videoUrl) {
|
function onRowCheck(cb, vid){
|
||||||
// cobalt.tools is a clean, ad-free high-quality downloader
|
const card = document.getElementById('vc-'+vid);
|
||||||
// Since it doesn't support direct URL params for downloading in the same way,
|
if (card) card.classList.toggle('sel', cb.checked);
|
||||||
// we'll open a popular service like 'ssyoutube' or similar which is easier for direct linking.
|
updateSelInfo();
|
||||||
const downloadUrl = videoUrl.replace('youtube.com', 'ssyoutube.com');
|
|
||||||
window.open(downloadUrl, '_blank');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLoadMoreButton(){
|
function updateLoadMoreButton(){
|
||||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
const b = document.getElementById('loadMoreBtn');
|
||||||
if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0) {
|
if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0){ b.style.display='block'; b.disabled=false; b.innerHTML='더 보기'; }
|
||||||
loadMoreBtn.style.display = 'block';
|
else b.style.display='none';
|
||||||
loadMoreBtn.disabled = false;
|
|
||||||
loadMoreBtn.innerHTML = 'Load More (Next 50)';
|
|
||||||
} else {
|
|
||||||
loadMoreBtn.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadVideo(videoUrl){ window.open(videoUrl.replace('youtube.com','ssyoutube.com'), '_blank'); }
|
||||||
function openVideoModal(videoId, title){
|
function openVideoModal(videoId, title){
|
||||||
const modal = document.getElementById('videoModal');
|
document.getElementById('modalTitle').textContent = title || '';
|
||||||
const container = document.getElementById('iframeContainer');
|
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+videoId+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
|
||||||
const modalTitle = document.getElementById('modalTitle');
|
document.getElementById('videoModal').style.display = 'flex';
|
||||||
modalTitle.textContent = title;
|
|
||||||
container.innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/' + videoId + '?autoplay=1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeVideoModal() {
|
|
||||||
document.getElementById('iframeContainer').innerHTML = '';
|
|
||||||
document.getElementById('videoModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
alert('URL이 클립보드에 복사되었습니다.');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
function closeVideoModal(){ document.getElementById('iframeContainer').innerHTML=''; document.getElementById('videoModal').style.display='none'; }
|
||||||
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
|
||||||
|
function copyToClipboard(text){ navigator.clipboard.writeText(text).then(()=>{ alert('URL이 클립보드에 복사되었습니다.'); }); }
|
||||||
|
|
||||||
// ---- 수집함에 담기 ----
|
// ---- 수집함에 담기 ----
|
||||||
function toggleSelectAll(checked){
|
function toggleSelectAll(checked){
|
||||||
document.querySelectorAll('.rowChk').forEach(cb => cb.checked = checked);
|
document.querySelectorAll('.rowChk').forEach(cb=>{ cb.checked=checked; const card=cb.closest('.vc-card'); if(card) card.classList.toggle('sel',checked); });
|
||||||
updateSelInfo();
|
updateSelInfo();
|
||||||
}
|
}
|
||||||
function updateSelInfo(){
|
function updateSelInfo(){
|
||||||
const checked = document.querySelectorAll('.rowChk:checked').length;
|
const n = document.querySelectorAll('.rowChk:checked').length;
|
||||||
const info = document.getElementById('selInfo');
|
const info = document.getElementById('selInfo'); if (info) info.textContent = n>0 ? (n+'개 선택됨') : '';
|
||||||
if (info) info.textContent = checked > 0 ? (checked + '개 선택됨') : '';
|
|
||||||
}
|
}
|
||||||
async function postCollect(items, btn){
|
async function postCollect(items, btn){
|
||||||
if (!items || items.length === 0){ alert('담을 영상이 없습니다.'); return; }
|
if (!items || items.length === 0){ alert('담을 영상이 없습니다.'); return; }
|
||||||
const original = btn ? btn.innerHTML : null;
|
const original = btn ? btn.innerHTML : null;
|
||||||
if (btn){ btn.disabled=true; btn.innerHTML='담는 중...'; }
|
if (btn){ btn.disabled=true; btn.innerHTML='담는 중...'; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/youtube/collect', {
|
const res = await fetch('/api/youtube/collect', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(items) });
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items)
|
|
||||||
});
|
|
||||||
const json = await res.json().catch(()=>({}));
|
const json = await res.json().catch(()=>({}));
|
||||||
if (!res.ok || (json && json.success === false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
if (!res.ok || (json && json.success === false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
||||||
const d = json.data || {};
|
const d = json.data || {};
|
||||||
alert('수집함에 담기 완료\\n저장 ' + (d.saved || 0) + '건, 중복/스킵 ' + (d.skipped || 0) + '건');
|
alert('수집함에 담기 완료\n저장 '+(d.saved||0)+'건, 중복/스킵 '+(d.skipped||0)+'건');
|
||||||
} catch (e) {
|
} catch(e){ alert('담기 실패: '+e.message); }
|
||||||
alert('담기 실패: ' + e.message);
|
finally { if (btn){ btn.disabled=false; btn.innerHTML=original; if(window.lucide) lucide.createIcons(); } }
|
||||||
} finally {
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = original; if (window.lucide) window.lucide.createIcons(); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function collectSelected(){
|
function collectSelected(){
|
||||||
const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb=>cb.value);
|
const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb=>cb.value);
|
||||||
@ -444,6 +299,7 @@
|
|||||||
if (!confirm('현재 로드된 '+allLoadedVideos.length+'개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return;
|
if (!confirm('현재 로드된 '+allLoadedVideos.length+'개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return;
|
||||||
postCollect(allLoadedVideos, document.getElementById('collectAllBtn'));
|
postCollect(allLoadedVideos, document.getElementById('collectAllBtn'));
|
||||||
}
|
}
|
||||||
|
/*]]>*/
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user