kv-tube/templates/channel.html
2026-01-12 09:41:27 +07:00

484 lines
No EOL
18 KiB
HTML
Executable file

{% extends "layout.html" %}
{% block content %}
<div class="yt-container yt-channel-page">
<!-- Channel Header (No Banner) -->
<div class="yt-channel-header">
<div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
{% if channel.avatar %}
<img src="{{ channel.avatar }}">
{% else %}
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
'Loading...' else 'C' }}</span>
{% endif %}
</div>
<div class="yt-channel-meta">
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
'Loading...' }}</h1>
<p class="yt-channel-handle" id="channelHandle">
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
%}@Loading...{% endif %}
</p>
<div class="yt-channel-stats">
<span id="channelStats"></span>
</div>
</div>
</div>
</div>
<!-- Video Grid -->
<div class="yt-section">
<div class="yt-section-header">
<div class="yt-tabs">
<a href="#" onclick="changeChannelTab('video', this); return false;" class="active">
<i class="fas fa-video"></i>
<span>Videos</span>
</a>
<a href="#" onclick="changeChannelTab('shorts', this); return false;">
<i class="fas fa-bolt"></i>
<span>Shorts</span>
</a>
</div>
<div class="yt-sort-options">
<a href="#" onclick="changeChannelSort('latest', this); return false;" class="active">
<i class="fas fa-clock"></i>
<span>Latest</span>
</a>
<a href="#" onclick="changeChannelSort('popular', this); return false;">
<i class="fas fa-fire"></i>
<span>Popular</span>
</a>
<a href="#" onclick="changeChannelSort('oldest', this); return false;">
<i class="fas fa-history"></i>
<span>Oldest</span>
</a>
</div>
</div>
<div class="yt-video-grid" id="channelVideosGrid">
<!-- Videos loaded via JS -->
</div>
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
</div>
</div>
<style>
.yt-channel-page {
padding-top: 40px;
padding-bottom: 40px;
max-width: 1200px;
margin: 0 auto;
}
/* Removed .yt-channel-banner */
.yt-channel-info-row {
display: flex;
align-items: flex-start;
gap: 32px;
margin-bottom: 32px;
padding: 0 16px;
}
.yt-channel-avatar-xl {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
/* Simpler color for no-banner look */
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
font-weight: bold;
color: white;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.yt-channel-avatar-xl img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-channel-meta {
padding-top: 12px;
}
.yt-channel-meta h1 {
font-size: 32px;
margin-bottom: 8px;
font-weight: 700;
}
.yt-channel-handle {
color: var(--yt-text-secondary);
font-size: 16px;
margin-bottom: 12px;
}
.yt-channel-stats {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.yt-section-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 24px;
padding: 0 16px;
border-bottom: none;
}
.yt-tabs {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-tabs a {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: var(--yt-text-secondary);
text-decoration: none;
transition: all 0.25s ease;
border: none;
background: transparent;
}
.yt-tabs a:hover {
color: var(--yt-text-primary);
background: var(--yt-bg-hover);
}
.yt-tabs a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
.yt-sort-options {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-sort-options a {
padding: 10px 20px;
border-radius: 12px;
background: transparent;
border: none;
color: var(--yt-text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.25s ease;
}
.yt-sort-options a:hover {
background: var(--yt-bg-hover);
color: var(--yt-text-primary);
}
.yt-sort-options a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
/* Shorts Card Styling override for Channel Page grid */
.yt-channel-short-card {
border-radius: var(--yt-radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.yt-channel-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb-container {
aspect-ratio: 9/16;
width: 100%;
position: relative;
}
.yt-short-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
.yt-channel-info-row {
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.yt-channel-avatar-xl {
width: 100px;
height: 100px;
font-size: 40px;
}
.yt-channel-meta h1 {
font-size: 24px;
}
.yt-section-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.yt-tabs {
width: 100%;
justify-content: center;
}
}
</style>
<script>
(function () {
// IIFE to prevent variable redeclaration errors on SPA navigation
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
var currentChannelSort = 'latest';
var currentChannelPage = 1;
var isChannelLoading = false;
var hasMoreChannelVideos = true;
var currentFilterType = 'video';
var channelId = "{{ channel.id }}";
// Store initial channel title from server template (don't overwrite with empty API data)
var initialChannelTitle = "{{ channel.title }}";
function init() {
console.log("Channel init called, fetching content...");
fetchChannelContent();
setupInfiniteScroll();
}
// Handle both initial page load and SPA navigation
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// DOM is already ready (SPA navigation)
init();
}
function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = '';
// Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
// Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
}
fetchChannelContent();
}
function changeChannelSort(sort, btn) {
if (isChannelLoading) return;
currentChannelSort = sort;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
fetchChannelContent();
}
async function fetchChannelContent() {
console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return;
}
isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator
if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else {
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
}
try {
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`);
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json();
console.log("Channel Videos Response:", videos);
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove)
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
// Check if response is an error
if (videos.error) {
hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return;
}
if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else {
// Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) {
// Use only proper channel/uploader fields - do NOT parse from title
let channelName = videos[0].channel || videos[0].uploader || '';
// Only update header if API returned a meaningful name
// (not empty, not just the channel ID, and not "Loading...")
if (channelName && channelName !== channelId &&
!channelName.startsWith('UC') && channelName !== 'Loading...') {
document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
}
// If no meaningful name from API, keep the initial template-rendered title
}
videos.forEach(video => {
const card = document.createElement('div');
if (currentFilterType === 'shorts') {
// Render Vertical Short Card
card.className = 'yt-channel-short-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-short-thumb-container">
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
</div>
<div class="yt-details" style="padding: 8px;">
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
</div>
`;
} else {
// Render Standard Video Card (Match Home)
card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats">
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
</p>
</div>
</div>
`;
}
grid.appendChild(card);
});
currentChannelPage++;
}
} catch (e) {
console.error(e);
} finally {
isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
}
}
function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchChannelContent();
}
}, { threshold: 0.1 });
observer.observe(trigger);
}
// Helpers - Define locally to ensure availability
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
try {
// Format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
} catch (e) {
return 'Recently';
}
}
// Expose functions globally for onclick handlers
window.changeChannelTab = changeChannelTab;
window.changeChannelSort = changeChannelSort;
})();
</script>
{% endblock %}