h-lab/src/main/resources/templates/channels.html
hehihoho3@gmail.com db8452d1ea feat(channels): 전체 선택을 현재 활성 국가 탭으로 한정
전체 선택/카운트/선택 영상 보기를 보이는 카드 기준으로 동작, 탭 전환 시
숨겨진 카드는 자동 선택 해제.

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

329 lines
17 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 - Channels</title>
</head>
<body>
<div layout:fragment="content">
<header class="flex justify-between items-center mb-4" style="margin-bottom: 2rem;">
<div>
<h1 class="text-xl font-bold mb-4">채널</h1>
<p class="text-muted">추적 중인 YouTube 채널을 관리합니다.</p>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer text-sm text-muted hover:text-white transition-colors">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
전체 선택
</label>
<button id="viewSelectedBtn" class="btn btn-outline" onclick="viewSelectedVideos()" style="display: none;">
<i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> 선택 영상 보기 (<span id="selectedCount">0</span>)
</button>
<button class="btn btn-primary" onclick="openAddModal()">
<i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> 채널 추가
</button>
</div>
</header>
<!-- 국가별 탭 -->
<div class="flex gap-2 mb-4" id="countryTabs" style="flex-wrap: wrap;">
<button type="button" class="ctab btn btn-primary" data-country="ALL" onclick="filterByCountry('ALL', this)">전체</button>
<button type="button" class="ctab btn btn-ghost" data-country="KR" onclick="filterByCountry('KR', this)">한국</button>
<button type="button" class="ctab btn btn-ghost" data-country="JP" onclick="filterByCountry('JP', this)">일본</button>
<button type="button" class="ctab btn btn-ghost" data-country="US" onclick="filterByCountry('US', this)">미국</button>
<button type="button" class="ctab btn btn-ghost" data-country="ETC" onclick="filterByCountry('ETC', this)">기타</button>
</div>
<div id="channelGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;">
<!-- Channel Card Loop -->
<div class="card channel-card relative cursor-pointer hover:border-[var(--primary)] transition-all"
th:each="channel : ${channels}"
th:data-country="${channel.country}"
th:onclick="'toggleCardSelection(this, event, ' + ${channel.id} + ')'"
style="user-select: none;">
<!-- Custom Checkbox -->
<div class="absolute top-4 left-4 z-10 flex items-center justify-center" style="width: 28px; height: 28px;">
<input type="checkbox" class="channel-checkbox cursor-pointer opacity-0 absolute w-full h-full z-20"
th:value="${channel.id}"
onchange="updateSelectedCount(); event.stopPropagation();">
<div class="custom-checkbox flex items-center justify-center" style="
width: 24px; height: 24px;
border: 2px solid var(--border-strong);
border-radius: 6px;
background: var(--surface-2);
transition: all 0.2s ease;
pointer-events: none;
">
<i data-lucide="check" style="width: 16px; height: 16px; color: var(--text); display: none;"></i>
</div>
</div>
<div class="flex justify-between items-start mb-4" style="padding-left: 2.5rem;">
<div class="flex items-center gap-2">
<img th:src="${channel.thumbnailUrl}" alt="Thumbnail"
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; background: var(--bg-primary);">
<div>
<h3 class="font-bold" th:text="${channel.title}">Tech Reviewer A</h3>
<div class="text-muted text-sm"
th:text="${#numbers.formatInteger(channel.subscriberCount ?: 0, 0, 'COMMA')} + ' 구독자'">
1.2M 구독자</div>
</div>
</div>
</div>
<div class="mb-4 text-sm text-muted" style="
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 2.5em;
" th:text="${channel.description}">
Channel description goes here...
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
<div class="p-4"
style="background: var(--surface-2); border-radius: 8px; text-align: center;">
<div class="text-muted mb-4" style="font-size: 0.75rem">영상 수</div>
<div class="font-bold" th:text="${#numbers.formatInteger(channel.videoCount ?: 0, 0, 'COMMA')}">450
</div>
</div>
<div class="p-4"
style="background: var(--surface-2); border-radius: 8px; text-align: center;">
<div class="text-muted mb-4" style="font-size: 0.75rem">총 조회수</div>
<div class="font-bold" th:text="${#numbers.formatInteger(channel.viewCount ?: 0, 0, 'COMMA')}">45K
</div>
</div>
</div>
<div class="flex gap-2" onclick="event.stopPropagation()">
<a th:href="@{/channels/{id}(id=${channel.id})}" class="btn btn-primary w-full"
style="font-size: 0.8rem; text-decoration: none; display: flex; align-items: center; justify-content: center;">
<i data-lucide="list" style="width: 14px; margin-right: 6px;"></i> 상세
</a>
<a th:href="'https://www.youtube.com/channel/' + ${channel.channelId}" target="_blank"
class="btn btn-ghost w-full"
style="font-size: 0.8rem; text-decoration: none; display: flex; align-items: center; justify-content: center;">
<i data-lucide="external-link" style="width: 14px; margin-right: 6px;"></i> 방문
</a>
<button class="btn btn-ghost w-full" style="color: var(--danger); font-size: 0.8rem;" th:onclick="'deleteChannel(' + ${channel.id} + '); event.stopPropagation();'">
<i data-lucide="trash-2" style="width: 14px; margin-right: 6px;"></i> 삭제
</button>
</div>
</div>
</div>
<!-- Add Channel Modal -->
<div id="addChannelModal"
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div
style="background: var(--bg-secondary); padding: 2rem; border-radius: 12px; width: 100%; max-width: 500px; border: 1px solid var(--border-color);">
<h2 class="text-xl font-bold mb-4">Add YouTube Channel</h2>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">Channel URL</label>
<input type="text" id="channelUrlInput" placeholder="https://www.youtube.com/@ChannelHandle"
style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary);">
</div>
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAddChannel()">채널 추가</button>
</div>
</div>
</div>
<script>
function openAddModal() {
const modal = document.getElementById('addChannelModal');
modal.style.display = 'flex';
document.getElementById('channelUrlInput').focus();
}
function closeAddModal() {
document.getElementById('addChannelModal').style.display = 'none';
document.getElementById('channelUrlInput').value = '';
}
async function submitAddChannel() {
const url = document.getElementById('channelUrlInput').value;
if (!url) {
alert('Please enter a URL');
return;
}
try {
const response = await fetch('/api/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url: url })
});
if (response.ok) {
alert('Channel added successfully!');
closeAddModal();
window.location.reload();
} else {
const errorData = await response.json();
alert('Failed to add channel: ' + (errorData.message || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred while adding the channel.');
}
}
async function deleteChannel(id) {
if (!confirm('정말로 이 채널과 관련된 모든 데이터를 삭제하시겠습니까?\\n삭제된 데이터는 복구할 수 없습니다.')) {
return;
}
try {
const response = await fetch('/api/channels/' + id, {
method: 'DELETE'
});
if (response.ok) {
alert('채널이 성공적으로 삭제되었습니다.');
window.location.reload();
} else {
const err = await response.json().catch(() => ({}));
alert('채널 삭제에 실패했습니다: ' + (err.message || '서버 오류'));
}
} catch (error) {
console.error('Error:', error);
alert('서버 통신 중 오류가 발생했습니다.');
}
}
// 현재 탭에서 보이는(숨겨지지 않은) 카드의 체크박스만 반환
function visibleCheckboxes() {
return Array.from(document.querySelectorAll('.channel-checkbox'))
.filter(cb => { const c = cb.closest('.card'); return c && c.style.display !== 'none'; });
}
function updateSelectedCount() {
const checkboxes = document.querySelectorAll('.channel-checkbox');
const visibleBoxes = visibleCheckboxes();
const checkedVisible = visibleBoxes.filter(cb => cb.checked);
const count = checkedVisible.length;
document.getElementById('selectedCount').innerText = count;
document.getElementById('viewSelectedBtn').style.display = count > 0 ? 'flex' : 'none';
// 전체 선택 상태는 '보이는 탭' 기준
const selectAll = document.getElementById('selectAllCheckbox');
if (count === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
} else if (count === visibleBoxes.length) {
selectAll.checked = true;
selectAll.indeterminate = false;
} else {
selectAll.checked = false;
selectAll.indeterminate = true;
}
// Update card visual state
checkboxes.forEach(cb => {
const card = cb.closest('.card');
const customCb = card.querySelector('.custom-checkbox');
// lucide 가 <i data-lucide> 를 <svg> 로 치환하므로 둘 다 잡는다(없으면 무시).
const checkIcon = customCb.querySelector('svg, i');
if (cb.checked) {
card.style.borderColor = 'var(--primary)';
card.style.background = 'rgba(59, 130, 246, 0.05)';
customCb.style.background = 'var(--primary)';
customCb.style.borderColor = 'var(--primary)';
if (checkIcon) checkIcon.style.display = 'block';
} else {
card.style.borderColor = 'var(--glass-border)';
card.style.background = 'var(--glass-bg)';
customCb.style.background = 'var(--surface-2)';
customCb.style.borderColor = 'var(--border-strong)';
if (checkIcon) checkIcon.style.display = 'none';
}
});
if (window.lucide) window.lucide.createIcons();
}
function toggleSelectAll() {
const isChecked = document.getElementById('selectAllCheckbox').checked;
// 현재 활성 탭에서 보이는 카드만 선택/해제
visibleCheckboxes().forEach(cb => cb.checked = isChecked);
updateSelectedCount();
}
function toggleCardSelection(card, event, id) {
// If the user clicked on a link or button, don't toggle selection
if (event.target.closest('a') || event.target.closest('button') || event.target.closest('input')) {
return;
}
const cb = card.querySelector('.channel-checkbox');
cb.checked = !cb.checked;
updateSelectedCount();
}
function viewSelectedVideos() {
const checkboxes = document.querySelectorAll('.channel-checkbox:checked');
const ids = Array.from(checkboxes).map(cb => cb.value).join(',');
if (ids) {
window.location.href = '/channels/videos?ids=' + ids;
}
}
// ---- 국가별 탭 필터 ----
function countryGroup(c) {
const v = (c || '').toUpperCase();
if (v === 'KR' || v === 'JP' || v === 'US') return v;
return 'ETC';
}
function filterByCountry(code, btn) {
document.querySelectorAll('#countryTabs .ctab').forEach(b => {
b.classList.toggle('btn-primary', b === btn);
b.classList.toggle('btn-ghost', b !== btn);
});
let shown = 0;
document.querySelectorAll('#channelGrid .channel-card').forEach(card => {
const g = countryGroup(card.getAttribute('data-country'));
const visible = (code === 'ALL') || (g === code);
card.style.display = visible ? '' : 'none';
if (visible) shown++;
else { // 숨겨진 카드는 선택 해제(전체 선택/선택 영상 보기를 현재 탭으로 한정)
const cb = card.querySelector('.channel-checkbox');
if (cb) cb.checked = false;
}
});
const empty = document.getElementById('emptyCountry');
if (empty) empty.style.display = shown === 0 ? 'block' : 'none';
updateSelectedCount();
}
function updateCountryTabCounts() {
const counts = { ALL: 0, KR: 0, JP: 0, US: 0, ETC: 0 };
document.querySelectorAll('#channelGrid .channel-card').forEach(card => {
counts.ALL++;
counts[countryGroup(card.getAttribute('data-country'))]++;
});
document.querySelectorAll('#countryTabs .ctab').forEach(b => {
const code = b.getAttribute('data-country');
const base = b.textContent.replace(/\s*\(\d+\)\s*$/, '');
b.textContent = base + ' (' + (counts[code] || 0) + ')';
});
}
document.addEventListener('DOMContentLoaded', updateCountryTabCounts);
// Close modal on outside click
window.onclick = function (event) {
const modal = document.getElementById('addChannelModal');
if (event.target == modal) {
closeAddModal();
}
}
</script>
</div>
</body>
</html>