- discover: filter converted to toggle chips + inline selects (matches 수집함); page-header + 사용법 modal (배율 지표/필터/고른 뒤 안내) - rework: 사용법 modal (전사·세그먼트·무음제거·내보내기·발행 guide) + page-header - dashboard: h1 "Dashboard" -> "대시보드" - channel_detail: sort-active header uses accent color (was invisible text-white) - multi_channel_videos/videos/production_detail: tokenize dark-assuming colors and title-link text-white for light-theme readability Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
519 lines
31 KiB
HTML
519 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
|
layout:decorate="~{layout/base}">
|
|
|
|
<body>
|
|
<div layout:fragment="content">
|
|
<header class="mb-4">
|
|
<h1 class="text-xl font-bold mb-2">YouTube Video Search</h1>
|
|
<p class="text-muted">Search YouTube videos using YouTube Data API.</p>
|
|
</header>
|
|
|
|
<!-- Search / Filter Area -->
|
|
<div class="card mb-4">
|
|
<form id="searchForm" onsubmit="event.preventDefault(); executeSearch();">
|
|
<div style="display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end;">
|
|
<!-- Keyword -->
|
|
<div style="display: flex; flex-direction: column; gap: 8px; flex: 2; min-width: 200px;">
|
|
<label class="text-sm font-bold text-muted">Keyword</label>
|
|
<input type="text" id="keyword" placeholder="Search keyword..."
|
|
class="p-2 w-full"
|
|
style="background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none; transition: border-color 0.2s;"
|
|
onfocus="this.style.borderColor='#3b82f6'" onblur="this.style.borderColor='var(--glass-border)'">
|
|
</div>
|
|
|
|
<!-- Country -->
|
|
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 180px;">
|
|
<label class="text-sm font-bold text-muted">Country</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 hover:text-white">
|
|
<input type="checkbox" name="region" value="JP" checked> JP
|
|
</label>
|
|
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
|
|
<input type="checkbox" name="region" value="US" checked> US
|
|
</label>
|
|
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
|
|
<input type="checkbox" name="region" value="KR"> KR
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Period -->
|
|
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
|
|
<label class="text-sm font-bold text-muted">Period</label>
|
|
<select id="periodDays" class="w-full"
|
|
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="1" style="background: var(--surface); color: var(--text);">Within 1 Day</option>
|
|
<option value="7" style="background: var(--surface); color: var(--text);">Within 7 Days</option>
|
|
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option>
|
|
<option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</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>
|
|
</div>
|
|
|
|
<!-- Format -->
|
|
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
|
|
<label class="text-sm font-bold text-muted">Format</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 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>
|
|
|
|
<!-- Page Size -->
|
|
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;">
|
|
<label class="text-sm font-bold text-muted">Load Size</label>
|
|
<select id="pageSize" class="w-full"
|
|
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="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>
|
|
</div>
|
|
</div>
|
|
|
|
<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</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- 수집 툴바 -->
|
|
<div class="flex items-center justify-between mb-3" style="gap:12px;">
|
|
<span id="selInfo" class="text-sm text-muted"></span>
|
|
<div class="flex gap-2">
|
|
<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> 선택 담기
|
|
</button>
|
|
<button class="btn btn-primary px-4 py-2 flex items-center gap-1" onclick="collectAll()" id="collectAllBtn">
|
|
<i data-lucide="download" style="width:16px;"></i> 전체 담기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Video List Table -->
|
|
<div class="card p-0" style="overflow-x: auto;">
|
|
<table class="w-full" style="border-collapse: collapse; text-align: left;">
|
|
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
|
|
<tr>
|
|
<th class="p-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>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="resultBody">
|
|
<tr>
|
|
<td colspan="8" class="p-6 text-center text-muted">Enter search conditions and click Search.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<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>
|
|
</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 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="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>
|
|
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size: 28px; line-height: 1; background: none; border: none; cursor: pointer;">×</button>
|
|
</div>
|
|
<div id="iframeContainer" style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; background: #000;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let allLoadedVideos = [];
|
|
let currentTokens = {};
|
|
let displayedCount = 0;
|
|
let currentCondition = {};
|
|
|
|
let currentSortColumn = 'performance';
|
|
let currentSortOrder = 'desc';
|
|
|
|
function handleSort(column) {
|
|
if (currentSortColumn === column) {
|
|
currentSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
currentSortColumn = column;
|
|
currentSortOrder = 'desc'; // Default to desc when changing columns
|
|
}
|
|
updateSortIcons();
|
|
|
|
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
|
// Re-render the table with the new sort (keeping the same number of displayed items or pageSize)
|
|
const targetLimit = 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() {
|
|
const keyword = document.getElementById('keyword').value;
|
|
const periodDays = parseInt(document.getElementById('periodDays').value);
|
|
const regionCheckboxes = document.querySelectorAll('input[name="region"]:checked');
|
|
const regions = Array.from(regionCheckboxes).map(cb => cb.value);
|
|
const formatRadios = document.querySelectorAll('input[name="format"]:checked');
|
|
const format = formatRadios.length > 0 ? formatRadios[0].value : 'SHORTS';
|
|
|
|
currentCondition = {
|
|
keyword: keyword,
|
|
regions: regions,
|
|
periodDays: periodDays,
|
|
format: format,
|
|
pageTokens: {}
|
|
};
|
|
|
|
allLoadedVideos = [];
|
|
currentTokens = {};
|
|
displayedCount = 0;
|
|
|
|
document.getElementById('resultBody').innerHTML = '';
|
|
document.getElementById('loadMoreBtn').style.display = 'none';
|
|
|
|
fetchMore(true);
|
|
}
|
|
|
|
async function fetchMore(isFirst) {
|
|
const searchBtn = document.getElementById('searchBtn');
|
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
const resultBody = document.getElementById('resultBody');
|
|
|
|
if (isFirst) {
|
|
searchBtn.disabled = true;
|
|
searchBtn.innerHTML = '<span class="text-sm">Searching...</span>';
|
|
resultBody.innerHTML = '<tr><td colspan="8" class="p-6 text-center text-muted">Loading results... This may take a moment due to multiple API calls.</td></tr>';
|
|
} else {
|
|
loadMoreBtn.disabled = true;
|
|
loadMoreBtn.innerHTML = 'Loading...';
|
|
}
|
|
|
|
currentCondition.pageTokens = currentTokens;
|
|
|
|
try {
|
|
const response = await fetch('/api/youtube/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(currentCondition)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('API request failed');
|
|
|
|
const pageDto = await response.json();
|
|
|
|
if (isFirst) {
|
|
resultBody.innerHTML = '';
|
|
}
|
|
|
|
allLoadedVideos = allLoadedVideos.concat(pageDto.items || []);
|
|
currentTokens = pageDto.nextTokens || {};
|
|
|
|
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
|
const targetLimit = isFirst ? size : displayedCount + size;
|
|
renderTable(targetLimit);
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
if (isFirst) {
|
|
resultBody.innerHTML = '<tr><td colspan="7" class="p-6 text-center text-danger">Error occurred during search.</td></tr>';
|
|
}
|
|
} finally {
|
|
if (isFirst) {
|
|
searchBtn.disabled = false;
|
|
searchBtn.innerHTML = '<i data-lucide="search" style="width: 18px; margin-right: 6px;"></i> Search';
|
|
if (window.lucide) window.lucide.createIcons();
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleLoadMore() {
|
|
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
|
if (displayedCount < allLoadedVideos.length) {
|
|
renderTable(displayedCount + size);
|
|
} else {
|
|
fetchMore(false);
|
|
}
|
|
}
|
|
|
|
function renderTable(limit) {
|
|
const resultBody = document.getElementById('resultBody');
|
|
|
|
if (allLoadedVideos.length === 0) {
|
|
resultBody.innerHTML = '<tr><td colspan="8" class="p-6 text-center text-muted">No results found.</td></tr>';
|
|
document.getElementById('loadMoreBtn').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Apply sort to allLoadedVideos
|
|
allLoadedVideos.sort((a, b) => {
|
|
let valA = 0, valB = 0;
|
|
if (currentSortColumn === 'views') {
|
|
valA = a.viewCount || 0;
|
|
valB = b.viewCount || 0;
|
|
} else if (currentSortColumn === 'subs') {
|
|
valA = a.subscriberCount || 0;
|
|
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 = ''; // Clear current rows
|
|
const actualLimit = Math.min(limit, allLoadedVideos.length);
|
|
const videosToRender = allLoadedVideos.slice(0, actualLimit);
|
|
|
|
videosToRender.forEach(video => {
|
|
const tr = document.createElement('tr');
|
|
tr.style.borderBottom = '1px solid var(--glass-border)';
|
|
|
|
const pubDate = new Date(video.publishedAt);
|
|
let formattedDate = pubDate.toLocaleDateString() + ' ' + pubDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
if (typeof moment !== 'undefined') {
|
|
formattedDate = moment(video.publishedAt).format('YYYY-MM-DD HH:mm:ss');
|
|
} else {
|
|
const yyyy = pubDate.getFullYear();
|
|
const mm = String(pubDate.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(pubDate.getDate()).padStart(2, '0');
|
|
const hh = String(pubDate.getHours()).padStart(2, '0');
|
|
const min = String(pubDate.getMinutes()).padStart(2, '0');
|
|
const ss = String(pubDate.getSeconds()).padStart(2, '0');
|
|
formattedDate = yyyy + '-' + mm + '-' + dd + ' ' + hh + ':' + min + ':' + ss;
|
|
}
|
|
|
|
const viewStr = video.viewCount != null ? video.viewCount.toLocaleString() : 'N/A';
|
|
const subStr = video.subscriberCount != null ? video.subscriberCount.toLocaleString() : 'N/A';
|
|
|
|
let perfStr = '-';
|
|
let perfStyle = 'color: var(--text-muted);';
|
|
if (video.viewCount != null && video.subscriberCount != null && video.subscriberCount > 0) {
|
|
const ratio = (video.viewCount / video.subscriberCount) * 100;
|
|
perfStr = ratio.toFixed(1) + '%';
|
|
if (ratio >= 100) {
|
|
perfStyle = 'color: #ef4444; font-weight: bold;'; // Red (hot performance)
|
|
} else if (ratio >= 30) {
|
|
perfStyle = 'color: #f59e0b; font-weight: bold;'; // Orange
|
|
} else {
|
|
perfStyle = 'color: #10b981; font-weight: bold;'; // Green
|
|
}
|
|
} else if (video.viewCount > 0 && video.subscriberCount === 0) {
|
|
perfStr = '∞%';
|
|
perfStyle = 'color: #ef4444; font-weight: bold;';
|
|
}
|
|
|
|
let tagsHtml = '';
|
|
if (video.hashtags && video.hashtags.length > 0) {
|
|
// Limit to top 5 tags to prevent huge cells
|
|
const topTags = video.hashtags.slice(0, 5);
|
|
const tagLinks = topTags.map(tag => {
|
|
const safeTag = tag.replace(/'/g, "\\'").replace(/"/g, """);
|
|
return '<span class="cursor-pointer hover:underline" onclick="searchByTag(\'' + safeTag + '\')">' + tag + '</span>';
|
|
});
|
|
tagsHtml = '<div class="text-xs mt-1 flex flex-wrap gap-1" style="color: #3b82f6; word-break: break-word;">' + tagLinks.join(' ') + '</div>';
|
|
}
|
|
|
|
// Escape double quotes for HTML attribute
|
|
const safeTitle = video.title.replace(/"/g, """);
|
|
|
|
tr.innerHTML =
|
|
'<td class="p-4">' +
|
|
'<input type="checkbox" class="rowChk" value="' + video.videoId + '" onchange="updateSelInfo()">' +
|
|
'</td>' +
|
|
'<td class="p-4">' +
|
|
'<div style="position: relative; display: inline-block; cursor: pointer;" data-videoid="' + video.videoId + '" data-videotitle="' + safeTitle + '" onclick="openVideoModal(this.dataset.videoid, this.dataset.videotitle)">' +
|
|
'<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; margin-top: 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>' +
|
|
'<button onclick="registerChannel(\'https://www.youtube.com/channel/' + video.channelId + '\')" class="text-xs" style="background: #3b82f6; color: var(--text); border: none; padding: 2px 6px; border-radius: 4px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background=\'#2563eb\'" onmouseout="this.style.background=\'#3b82f6\'">등록</button>' +
|
|
'</div>' +
|
|
tagsHtml +
|
|
'</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>';
|
|
resultBody.appendChild(tr);
|
|
});
|
|
|
|
displayedCount = actualLimit;
|
|
updateLoadMoreButton();
|
|
|
|
const selectAll = document.getElementById('selectAll');
|
|
if (selectAll) selectAll.checked = false;
|
|
updateSelInfo();
|
|
|
|
if (window.lucide) {
|
|
window.lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
function updateLoadMoreButton() {
|
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0) {
|
|
loadMoreBtn.style.display = 'block';
|
|
loadMoreBtn.disabled = false;
|
|
loadMoreBtn.innerHTML = 'Load More (Next 50)';
|
|
} else {
|
|
loadMoreBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function openVideoModal(videoId, title) {
|
|
const modal = document.getElementById('videoModal');
|
|
const container = document.getElementById('iframeContainer');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
|
|
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() {
|
|
const modal = document.getElementById('videoModal');
|
|
const container = document.getElementById('iframeContainer');
|
|
|
|
container.innerHTML = '';
|
|
modal.style.display = 'none';
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
alert('URL이 클립보드에 복사되었습니다.');
|
|
}).catch(err => {
|
|
console.error('Failed to copy text: ', err);
|
|
});
|
|
}
|
|
|
|
async function registerChannel(channelUrl) {
|
|
if (!confirm('이 채널을 시스템에 등록하시겠습니까?')) return;
|
|
try {
|
|
const response = await fetch('/api/channels', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: channelUrl })
|
|
});
|
|
if (response.ok) {
|
|
alert('채널이 성공적으로 등록되었습니다.\\n채널 관리 메뉴(/channels)에서 확인하실 수 있습니다.');
|
|
} else {
|
|
const err = await response.json().catch(() => ({}));
|
|
alert('등록 실패: ' + (err.message || '서버 오류가 발생했습니다.'));
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert('서버와 통신 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
function searchByTag(tag) {
|
|
const keywordInput = document.getElementById('keyword');
|
|
keywordInput.value = tag;
|
|
executeSearch();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
// ---- 수집함에 담기 ----
|
|
function toggleSelectAll(checked) {
|
|
document.querySelectorAll('.rowChk').forEach(cb => cb.checked = checked);
|
|
updateSelInfo();
|
|
}
|
|
|
|
function updateSelInfo() {
|
|
const checked = document.querySelectorAll('.rowChk:checked').length;
|
|
const info = document.getElementById('selInfo');
|
|
if (info) info.textContent = checked > 0 ? (checked + '개 선택됨') : '';
|
|
}
|
|
|
|
async function postCollect(items, btn) {
|
|
if (!items || items.length === 0) { alert('담을 영상이 없습니다.'); return; }
|
|
const original = btn ? btn.innerHTML : null;
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '담는 중...'; }
|
|
try {
|
|
const res = await fetch('/api/youtube/collect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(items)
|
|
});
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!res.ok || (json && json.success === false)) throw new Error((json && json.message) || ('HTTP ' + res.status));
|
|
const d = json.data || {};
|
|
alert('수집함에 담기 완료\\n저장 ' + (d.saved || 0) + '건, 중복/스킵 ' + (d.skipped || 0) + '건');
|
|
} catch (e) {
|
|
alert('담기 실패: ' + e.message);
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; if (window.lucide) window.lucide.createIcons(); }
|
|
}
|
|
}
|
|
|
|
function collectSelected() {
|
|
const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb => cb.value);
|
|
if (ids.length === 0) { alert('선택된 영상이 없습니다. 체크박스로 선택하세요.'); return; }
|
|
const set = new Set(ids);
|
|
const items = allLoadedVideos.filter(v => set.has(v.videoId));
|
|
postCollect(items, document.getElementById('collectSelBtn'));
|
|
}
|
|
|
|
function collectAll() {
|
|
if (allLoadedVideos.length === 0) { alert('먼저 검색하세요.'); return; }
|
|
if (!confirm('현재 로드된 ' + allLoadedVideos.length + '개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return;
|
|
postCollect(allLoadedVideos, document.getElementById('collectAllBtn'));
|
|
}
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html> |