h-lab/src/main/resources/templates/channel_detail.html
hehihoho3@gmail.com c0ade287c2 feat(ui): 썸네일 클릭 미리보기를 칸반·채널상세·프로덕션상세에 추가
수집함·발굴·대시보드처럼, 썸네일 있는 나머지 화면에서도 썸네일을 클릭하면
YouTube 9:16 미리보기 팝업이 뜨도록 통일.
- 칸반(board): 카드 썸네일 클릭(드래그와 분리 위해 stopPropagation)
- 채널상세(channel_detail): 영상 목록 썸네일(제목 링크는 YouTube 유지)
- 프로덕션상세(production_detail): videoUrl에서 11자리 id 추출해 임베드,
  id 없으면 새 탭 폴백
각 페이지에 동일 모달(배경/ESC 닫힘, 닫을 때 iframe 비워 재생 중지) 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:18:09 +09:00

370 lines
20 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 - Channel Detail</title>
</head>
<body>
<div layout:fragment="content">
<header class="mb-6">
<div class="flex items-center gap-2 mb-4">
<a th:href="@{/channels}" class="text-muted hover:text-white transition-colors">
<i data-lucide="arrow-left" style="width: 20px;"></i>
</a>
<h1 class="text-2xl font-bold" th:text="${channel.title}">Channel Name</h1>
</div>
<div class="card p-6 mb-6 flex justify-between items-start">
<div class="flex gap-4">
<img th:src="${channel.thumbnailUrl}" alt="Thumbnail"
style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover; background: var(--bg-primary);">
<div>
<div class="text-muted mb-2" th:text="${channel.description}">Description...</div>
<div class="flex gap-4 text-sm text-muted">
<div><i data-lucide="users" style="width: 14px; display: inline;"></i> <span
th:text="${#numbers.formatInteger(channel.subscriberCount, 0, 'COMMA')}">0</span>
Subs</div>
<div><i data-lucide="play" style="width: 14px; display: inline;"></i> <span
th:text="${#numbers.formatInteger(channel.viewCount, 0, 'COMMA')}">0</span> Views
</div>
<div><i data-lucide="video" style="width: 14px; display: inline;"></i> <span
th:text="${#numbers.formatInteger(channel.videoCount, 0, 'COMMA')}">0</span> Videos
</div>
</div>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" th:onclick="'syncVideos(' + ${channel.id} + ')'" id="syncBtn">
<i data-lucide="refresh-cw" style="width: 16px; margin-right: 6px;"></i> Sync Videos
</button>
<button class="btn btn-outline" th:onclick="'extractAllScripts(' + ${channel.id} + ')'"
id="extractAllBtn">
<i data-lucide="file-text" style="width: 16px; margin-right: 6px;"></i> Extract All
</button>
<a th:href="'https://www.youtube.com/channel/' + ${channel.channelId}" target="_blank"
class="btn btn-ghost">
<i data-lucide="external-link" style="width: 16px;"></i>
</a>
</div>
</div>
</header>
<!-- 성장 추이 -->
<div class="card mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">성장 추이</h3>
<button class="btn btn-secondary" th:onclick="'captureSnapshot(' + ${channel.id} + ')'" id="snapBtn">
<i data-lucide="camera" style="width: 16px; margin-right: 6px;"></i> 지금 기록
</button>
</div>
<div id="growthEmpty" class="text-muted text-sm" style="display:none;">
아직 스냅샷이 없습니다. "지금 기록"을 누르거나 매일 03:00 자동 기록을 기다리세요. 날짜가 쌓이면 추이가 그려집니다.
</div>
<div style="position: relative; height: 260px;">
<canvas id="growthChart"></canvas>
</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">Video</th>
<th class="p-4 text-sm font-bold text-muted text-center">Script</th>
<th class="p-4 text-sm font-bold text-muted sortable" onclick="sortTable('views')">
Views <i data-lucide="arrow-up-down"
style="width: 12px; height: 12px; display: inline;"></i>
</th>
<th class="p-4 text-sm font-bold text-muted sortable" onclick="sortTable('likes')">
Likes <i data-lucide="arrow-up-down"
style="width: 12px; height: 12px; display: inline;"></i>
</th>
<th class="p-4 text-sm font-bold text-muted sortable" onclick="sortTable('duration')">
Duration <i data-lucide="arrow-up-down"
style="width: 12px; height: 12px; display: inline;"></i>
</th>
<th class="p-4 text-sm font-bold text-muted sortable" onclick="sortTable('published')">
Published <i data-lucide="arrow-up-down"
style="width: 12px; height: 12px; display: inline;"></i>
</th>
</tr>
</thead>
<tbody>
<tr th:each="video : ${videos}"
style="border-bottom: 1px solid var(--glass-border); transition: background 0.2s;">
<td class="p-4">
<div class="flex items-center gap-4">
<div class="block relative group" style="cursor:pointer;" title="미리보기"
th:attr="data-vid=${video.videoId},data-title=${video.title}"
onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<img th:src="${video.thumbnailUrl}" alt="Thumb"
style="width: 120px; height: 68px; object-fit: cover; border-radius: 6px;">
</div>
<a th:href="'https://www.youtube.com/watch?v=' + ${video.videoId}" target="_blank"
class="font-bold text-sm hover:text-[var(--primary)] transition-colors"
style="max-width: 400px; line-height: 1.4;" th:text="${video.title}">Video Title</a>
</div>
</td>
<td class="p-4 text-center">
<span th:if="${video.hasScript}" class="badge badge-success">
<i data-lucide="check" style="width: 12px;"></i>
</span>
<button th:unless="${video.hasScript}" class="btn btn-sm btn-ghost"
th:onclick="'extractScript(this, ' + ${video.id} + ')'" title="Extract Transcript">
<i data-lucide="download" style="width: 14px;"></i>
</button>
</td>
<td class="p-4 text-sm" th:data-value="${video.viewCount}">
<span th:text="${#numbers.formatInteger(video.viewCount, 0, 'COMMA')}">0</span>
</td>
<td class="p-4 text-sm" th:data-value="${video.likeCount}">
<span th:text="${#numbers.formatInteger(video.likeCount, 0, 'COMMA')}">0</span>
</td>
<td class="p-4 text-sm text-muted" th:text="${video.duration}">10:00</td>
<td class="p-4 text-sm text-muted" th:data-value="${video.publishedAt}"
th:text="${#temporals.format(video.publishedAt, 'yyyy-MM-dd HH:mm')}">2025-01-01</td>
</tr>
</tbody>
</table>
<div th:if="${#lists.isEmpty(videos)}" class="p-8 text-center text-muted">
No videos found. Click "Sync Videos" to fetch from YouTube.
</div>
</div>
<!-- Chart.js (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script th:inline="javascript">
async function syncVideos(channelId) {
const btn = document.getElementById('syncBtn');
if (!confirm('This may take a while. Continue?')) return;
try {
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="animate-spin">⌛</span> Syncing...';
const response = await fetch(`/api/channels/${channelId}/sync`, {
method: 'POST'
});
if (response.ok) {
alert('Sync completed successfully!');
window.location.reload();
} else {
throw new Error('Sync failed');
}
} catch (e) {
console.error(e);
alert('Error syncing videos.');
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
async function extractScript(btn, videoId) {
try {
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="animate-spin">⌛</span>';
const response = await fetch(`/api/channels/videos/${videoId}/script`, {
method: 'POST'
});
if (response.ok) {
// Replace button with success badge
const badge = document.createElement('span');
badge.className = 'badge badge-success';
badge.innerHTML = '<i data-lucide="check" style="width: 12px;"></i>';
btn.parentNode.replaceChild(badge, btn);
lucide.createIcons();
} else {
throw new Error('Extraction failed');
}
} catch (e) {
console.error(e);
alert('Failed to extract script.');
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
async function extractAllScripts(channelId) {
const btn = document.getElementById('extractAllBtn');
if (!confirm('This will extract scripts for ALL videos without one. It may take a long time. Continue?')) return;
try {
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="animate-spin">⌛</span> Processing...';
const response = await fetch(`/api/channels/${channelId}/scripts/all`, {
method: 'POST'
});
if (response.ok) {
alert('Bulk extraction initiated/completed. Refreshing page...');
window.location.reload();
} else {
throw new Error('Bulk extraction failed');
}
} catch (e) {
console.error(e);
alert('Error starting bulk extraction.');
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
// Basic sort function
let sortState = { column: 'published', direction: 'desc' }; // Default default
function sortTable(column) {
const tbody = document.querySelector('table tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const headers = document.querySelectorAll('th.sortable');
if (sortState.column === column) {
sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
sortState.column = column;
sortState.direction = 'desc'; // Default to desc for new column (often more useful)
}
// Update Icons
headers.forEach(th => {
const icon = th.querySelector('i');
if (icon) {
const colName = th.getAttribute('onclick').match(/'([^']+)'/)[1];
if (colName === sortState.column) {
th.style.color = 'var(--accent)';
icon.setAttribute('data-lucide', sortState.direction === 'asc' ? 'chevron-up' : 'chevron-down');
} else {
th.style.color = '';
icon.setAttribute('data-lucide', 'arrow-up-down');
}
}
});
lucide.createIcons();
const colIndexMap = {
'views': 2,
'likes': 3,
'duration': 4,
'published': 5
};
const idx = colIndexMap[column];
rows.sort((a, b) => {
const cellA = a.cells[idx];
const cellB = b.cells[idx];
let valA = cellA.getAttribute('data-value') || cellA.innerText.trim();
let valB = cellB.getAttribute('data-value') || cellB.innerText.trim();
// If it's a numeric column (Views, Likes), force number comparison
if (column === 'views' || column === 'likes') {
valA = parseFloat(valA.toString().replace(/,/g, '')) || 0;
valB = parseFloat(valB.toString().replace(/,/g, '')) || 0;
}
if (valA < valB) return sortState.direction === 'asc' ? -1 : 1;
if (valA > valB) return sortState.direction === 'asc' ? 1 : -1;
return 0;
});
rows.forEach(row => tbody.appendChild(row));
}
// ===== 성장 추이 차트 =====
const CHANNEL_ID = /*[[${channel.id}]]*/ 0;
let growthChart = null;
async function loadGrowth() {
try {
const res = await fetch('/api/v1/channels/' + CHANNEL_ID + '/growth');
const json = await res.json();
const data = json.data || [];
const empty = document.getElementById('growthEmpty');
const canvas = document.getElementById('growthChart');
if (data.length === 0) {
empty.style.display = 'block';
canvas.style.display = 'none';
return;
}
empty.style.display = 'none';
canvas.style.display = 'block';
const labels = data.map(s => s.snapshotDate);
const subs = data.map(s => s.subscriberCount);
const views = data.map(s => s.viewCount);
if (growthChart) growthChart.destroy();
growthChart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: labels,
datasets: [
{ label: '구독자', data: subs, borderColor: '#7C3AED', backgroundColor: 'rgba(124,58,237,0.1)', yAxisID: 'y', tension: 0.3, fill: true },
{ label: '총 조회수', data: views, borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', yAxisID: 'y1', tension: 0.3, fill: true }
]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: 'var(--text-2)' } } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'var(--surface-2)' } },
y: { position: 'left', ticks: { color: '#a78bfa' }, grid: { color: 'var(--surface-2)' }, title: { display: true, text: '구독자', color: '#a78bfa' } },
y1: { position: 'right', ticks: { color: '#34d399' }, grid: { drawOnChartArea: false }, title: { display: true, text: '조회수', color: '#34d399' } }
}
}
});
} catch (e) { console.error('growth load failed', e); }
}
async function captureSnapshot(channelId) {
const btn = document.getElementById('snapBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '기록 중...';
try {
const res = await fetch('/api/v1/channels/' + channelId + '/snapshot', { method: 'POST' });
if (!res.ok) throw new Error('HTTP ' + res.status);
await loadGrowth();
} catch (e) { alert('스냅샷 기록 실패: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = orig; if (window.lucide) lucide.createIcons(); }
}
// ----- 영상 미리보기 -----
function openVideoModal(vid, title){
document.getElementById('vmTitle').textContent = title || '미리보기';
document.getElementById('vmFrame').innerHTML = '<iframe src="https://www.youtube.com/embed/'+vid+'?autoplay=1" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').style.display = 'flex';
}
function closeVideoModal(){
document.getElementById('vmFrame').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
loadGrowth();
</script>
<!-- 영상 미리보기 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; inset:0; background:rgba(0,0,0,0.85); align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this) closeVideoModal()">
<div style="background:var(--surface); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:14px; padding:11px 14px; border-bottom:1px solid var(--border);">
<span id="vmTitle" class="text-sm font-bold truncate" style="max-width:300px;">미리보기</span>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:24px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
</div>
<div id="vmFrame" style="width:min(360px,90vw); aspect-ratio:9/16; max-height:78vh; background:#000;"></div>
</div>
</div>
<style>#vmFrame iframe { width:100%; height:100%; border:0; display:block; }</style>
</div>
</body>
</html>