461 lines
No EOL
16 KiB
HTML
461 lines
No EOL
16 KiB
HTML
{% 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">Subscribe for more</span>
|
|
</div>
|
|
<div class="yt-channel-actions">
|
|
<button class="yt-subscribe-btn-lg" id="subscribeChannelBtn">Subscribe</button>
|
|
</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">Videos</a>
|
|
<a href="#" onclick="changeChannelTab('shorts', this); return false;">Shorts</a>
|
|
</div>
|
|
|
|
<div class="yt-sort-options">
|
|
<a href="#" onclick="changeChannelSort('latest', this); return false;" class="active">Latest</a>
|
|
<a href="#" onclick="changeChannelSort('popular', this); return false;">Popular</a>
|
|
<a href="#" onclick="changeChannelSort('oldest', this); return false;">Oldest</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: 0;
|
|
background: var(--yt-bg-secondary);
|
|
padding: 4px;
|
|
border-radius: 24px;
|
|
position: relative;
|
|
}
|
|
|
|
.yt-tabs a {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--yt-text-secondary);
|
|
text-decoration: none;
|
|
padding: 8px 24px;
|
|
border-radius: 20px;
|
|
border-bottom: none;
|
|
z-index: 1;
|
|
transition: color 0.2s;
|
|
position: relative;
|
|
}
|
|
|
|
.yt-tabs a:hover {
|
|
color: var(--yt-text-primary);
|
|
}
|
|
|
|
.yt-tabs a.active {
|
|
color: var(--yt-bg-primary);
|
|
background: var(--yt-text-primary);
|
|
/* The "slider" is actually the active pill moving */
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.yt-sort-options {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.yt-sort-options a {
|
|
padding: 6px 16px;
|
|
border-radius: 16px;
|
|
background: transparent;
|
|
border: 1px solid var(--yt-border);
|
|
color: var(--yt-text-secondary);
|
|
text-decoration: none;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.yt-sort-options a:hover {
|
|
background: var(--yt-bg-hover);
|
|
color: var(--yt-text-primary);
|
|
}
|
|
|
|
.yt-sort-options a.active {
|
|
background: var(--yt-bg-secondary);
|
|
color: var(--yt-text-primary);
|
|
border-color: var(--yt-text-primary);
|
|
}
|
|
|
|
/* 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>
|
|
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
|
|
|
let currentChannelSort = 'latest';
|
|
let currentChannelPage = 1;
|
|
let isChannelLoading = false;
|
|
let hasMoreChannelVideos = true;
|
|
let currentFilterType = 'video';
|
|
const channelId = "{{ channel.id }}";
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
console.log("DOMContentLoaded fired, calling fetchChannelContent...");
|
|
console.log("typeof fetchChannelContent:", typeof fetchChannelContent);
|
|
if (typeof fetchChannelContent === 'function') {
|
|
fetchChannelContent();
|
|
} else {
|
|
console.error("fetchChannelContent is NOT a function!");
|
|
}
|
|
setupInfiniteScroll();
|
|
});
|
|
|
|
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]) {
|
|
// Try multiple sources for channel name
|
|
let channelName = videos[0].uploader || videos[0].channel || '';
|
|
|
|
// If still empty, try to get from video title (sometimes includes " - ChannelName")
|
|
if (!channelName && videos[0].title) {
|
|
const parts = videos[0].title.split(' - ');
|
|
if (parts.length > 1) channelName = parts[parts.length - 1];
|
|
}
|
|
|
|
// Final fallback: use channel ID
|
|
if (!channelName) channelName = channelId;
|
|
|
|
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();
|
|
|
|
// Update browser URL to show friendly name
|
|
const friendlyUrl = `/channel/@${encodeURIComponent(channelName.replace(/\s+/g, ''))}`;
|
|
window.history.replaceState({ channelId: channelId }, '', friendlyUrl);
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %} |