UI Polish: Mobile Categories, Lazy Loading, and CSS Fixes

This commit is contained in:
KV-Tube Deployer 2025-12-17 20:43:35 +07:00
parent 665a8209e4
commit 4472f6852a
5 changed files with 304 additions and 139 deletions

View file

@ -58,6 +58,8 @@ html {
font-size: 16px; font-size: 16px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--yt-bg-primary);
/* Fix white bar issue */
} }
body { body {
@ -66,6 +68,7 @@ body {
color: var(--yt-text-primary); color: var(--yt-text-primary);
line-height: 1.4; line-height: 1.4;
overflow-x: hidden; overflow-x: hidden;
min-height: 100vh;
} }
a { a {
@ -194,7 +197,7 @@ button {
.yt-search-input { .yt-search-input {
flex: 1; flex: 1;
height: 40px; height: 40px;
background: var(--yt-bg-primary); background: var(--yt-bg-secondary);
border: 1px solid var(--yt-border); border: 1px solid var(--yt-border);
border-right: none; border-right: none;
border-radius: 20px 0 0 20px; border-radius: 20px 0 0 20px;
@ -371,6 +374,7 @@ button {
padding: 12px 0 24px; padding: 12px 0 24px;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
flex-wrap: nowrap;
-ms-overflow-style: none; -ms-overflow-style: none;
} }
@ -430,7 +434,12 @@ button {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 0.3s ease; opacity: 0;
transition: opacity 0.5s ease, transform 0.3s ease;
}
.yt-thumbnail.loaded {
opacity: 1;
} }
.yt-video-card:hover .yt-thumbnail { .yt-video-card:hover .yt-thumbnail {
@ -819,11 +828,28 @@ button {
@media (max-width: 768px) { @media (max-width: 768px) {
.yt-header-center { .yt-header-center {
display: none; display: flex;
/* Show search on mobile */
margin: 0 8px;
max-width: none;
flex: 1;
justify-content: center;
}
.yt-search-input {
padding: 0 12px;
font-size: 14px;
border-radius: 18px 0 0 18px;
}
.yt-search-btn {
width: 48px;
border-radius: 0 18px 18px 0;
} }
.yt-mobile-search { .yt-mobile-search {
display: flex; display: none;
/* Hide icon since bar is visible */
} }
.yt-signin-text { .yt-signin-text {
@ -831,8 +857,9 @@ button {
} }
.yt-video-grid { .yt-video-grid {
grid-template-columns: 1fr; grid-template-columns: repeat(2, 1fr);
gap: 0; gap: 8px;
padding: 0 4px;
} }
.yt-video-card { .yt-video-card {
@ -851,6 +878,10 @@ button {
.yt-categories { .yt-categories {
padding: 8px 0 16px; padding: 8px 0 16px;
gap: 8px; gap: 8px;
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
white-space: nowrap;
} }
.yt-category-pill { .yt-category-pill {
@ -1171,3 +1202,130 @@ button {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Floating Back Button */
.yt-floating-back {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
background: var(--yt-accent-blue);
color: white;
border-radius: 50%;
display: none;
/* Hidden on desktop */
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 2000;
cursor: pointer;
transition: transform 0.2s, background 0.2s;
border: none;
}
.yt-floating-back:active {
transform: scale(0.95);
background: #2c95dd;
}
@media (max-width: 768px) {
.yt-floating-back {
display: flex;
/* Show only on mobile */
}
}
/* Mobile V2 Overrides */
@media (max-width: 768px) {
.yt-video-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 8px !important;
padding: 0 4px !important;
}
.yt-video-card {
padding: 4px !important;
}
.yt-categories {
display: flex !important;
flex-wrap: nowrap !important;
grid-template-rows: none !important;
padding-right: 16px !important;
white-space: nowrap !important;
}
.yt-floating-back {
background: var(--yt-accent-red) !important;
}
.yt-floating-back:active {
background: #cc0000 !important;
}
}
/* Mobile V4 Overrides - Pixel Perfect Polish */
@media (max-width: 768px) {
/* Optimize Categories */
.yt-categories {
padding: 8px 0 8px 8px !important;
/* Left padding for start, no right padding */
width: 100% !important;
mask-image: linear-gradient(to right, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, black 95%, transparent 100%);
}
.yt-chip {
font-size: 12px !important;
padding: 6px 12px !important;
height: 30px !important;
border-radius: 6px !important;
}
/* Maximize Thumbnails */
.yt-main {
padding: 0 !important;
/* Edge to edge */
}
.yt-video-grid {
gap: 1px !important;
/* Minimal gap */
padding: 0 !important;
background: var(--yt-bg-primary);
}
.yt-video-card,
.skeleton-card {
padding: 0 !important;
margin-bottom: 4px !important;
}
.yt-thumbnail-container,
.skeleton-thumb {
border-radius: 6px !important;
}
.yt-video-details {
padding: 6px 8px 12px !important;
}
.yt-video-title {
font-size: 13px !important;
line-height: 1.2 !important;
}
/* Reduce header padding to match */
.yt-header {
padding: 0 12px !important;
}
/* Filter bar spacing */
.yt-filter-bar {
padding-left: 0 !important;
padding-right: 0 !important;
}
}

View file

@ -36,6 +36,31 @@ let currentPage = 1;
let isLoading = false; let isLoading = false;
let hasMore = true; // Track if there are more videos to load let hasMore = true; // Track if there are more videos to load
// --- Lazy Loading ---
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.onload = () => img.classList.add('loaded');
img.removeAttribute('data-src');
}
observer.unobserve(img);
}
});
}, {
rootMargin: '50px 0px',
threshold: 0.1
});
window.observeImages = function () {
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
};
// --- Infinite Scroll --- // --- Infinite Scroll ---
function initInfiniteScroll() { function initInfiniteScroll() {
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
@ -234,7 +259,7 @@ async function loadTrending(reset = true) {
card.innerHTML = ` card.innerHTML = `
<div class="yt-thumbnail-container"> <div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy"> <img class="yt-thumbnail" data-src="${video.thumbnail}" alt="${escapeHtml(video.title)}">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''} ${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div> </div>
<div class="yt-video-details"> <div class="yt-video-details">
@ -255,6 +280,7 @@ async function loadTrending(reset = true) {
sectionDiv.appendChild(scrollContainer); sectionDiv.appendChild(scrollContainer);
resultsArea.appendChild(sectionDiv); resultsArea.appendChild(sectionDiv);
}); });
if (window.observeImages) window.observeImages();
return; return;
} }
@ -299,7 +325,7 @@ function displayResults(videos, append = false) {
card.style.width = '100%'; card.style.width = '100%';
card.style.maxWidth = '200px'; card.style.maxWidth = '200px';
card.innerHTML = ` card.innerHTML = `
<img src="${video.thumbnail}" class="yt-short-thumb" style="width:100%; aspect-ratio:9/16; height:auto;" loading="lazy"> <img data-src="${video.thumbnail}" class="yt-short-thumb" style="width:100%; aspect-ratio:9/16; height:auto;">
<p class="yt-short-title">${escapeHtml(video.title)}</p> <p class="yt-short-title">${escapeHtml(video.title)}</p>
<p class="yt-short-views">${formatViews(video.view_count)} views</p> <p class="yt-short-views">${formatViews(video.view_count)} views</p>
`; `;
@ -308,7 +334,7 @@ function displayResults(videos, append = false) {
card.className = 'yt-video-card'; card.className = 'yt-video-card';
card.innerHTML = ` card.innerHTML = `
<div class="yt-thumbnail-container"> <div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy"> <img class="yt-thumbnail" data-src="${video.thumbnail}" alt="${escapeHtml(video.title)}">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''} ${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div> </div>
<div class="yt-video-details"> <div class="yt-video-details">
@ -337,6 +363,8 @@ function displayResults(videos, append = false) {
}); });
resultsArea.appendChild(card); resultsArea.appendChild(card);
}); });
if (window.observeImages) window.observeImages();
} }
// Format view count (YouTube style) // Format view count (YouTube style)
@ -425,28 +453,51 @@ function initTheme() {
let savedTheme = localStorage.getItem('theme'); let savedTheme = localStorage.getItem('theme');
// If no saved preference, use Time of Day (Auto) // If no saved preference, use Time of Day (Auto)
// Approximation: 6 AM to 6 PM is Light (Sunrise/Sunset)
if (!savedTheme) { if (!savedTheme) {
const hour = new Date().getHours(); const hour = new Date().getHours();
savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark'; savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark';
} }
document.documentElement.setAttribute('data-theme', savedTheme); setTheme(savedTheme, false); // Initial set without saving (already saved or computed)
}
// Update toggle if exists function setTheme(theme, save = true) {
const toggle = document.getElementById('themeToggle'); document.documentElement.setAttribute('data-theme', theme);
if (toggle) { if (save) {
toggle.checked = savedTheme === 'dark'; localStorage.setItem('theme', theme);
}
// Update UI Buttons (if on settings page)
const btnLight = document.getElementById('themeBtnLight');
const btnDark = document.getElementById('themeBtnDark');
if (btnLight && btnDark) {
btnLight.classList.remove('active');
btnDark.classList.remove('active');
if (theme === 'light') btnLight.classList.add('active');
else btnDark.classList.add('active');
} }
} }
function toggleTheme() { // Ensure theme persists on back navigation (BFCache)
const current = document.documentElement.getAttribute('data-theme'); window.addEventListener('pageshow', (event) => {
const newTheme = current === 'light' ? 'dark' : 'light'; // Re-apply theme from storage to ensure it matches user preference
// even if page was restored from cache with old state
document.documentElement.setAttribute('data-theme', newTheme); const savedTheme = localStorage.getItem('theme');
localStorage.setItem('theme', newTheme); if (savedTheme) {
setTheme(savedTheme, false);
} else {
initTheme();
} }
});
// Sync across tabs
window.addEventListener('storage', (event) => {
if (event.key === 'theme') {
setTheme(event.newValue, false);
}
});
// --- Profile Logic --- // --- Profile Logic ---
async function updateProfile(e) { async function updateProfile(e) {

View file

@ -42,26 +42,11 @@
<!-- Shorts Section --> <!-- Shorts Section -->
<div id="shortsSection" class="yt-section">
<div class="yt-section-header">
<h2><i class="fas fa-bolt"></i> Shorts</h2>
</div>
<div class="yt-shorts-container">
<button class="yt-shorts-arrow yt-shorts-left" onclick="scrollShorts('left')">
<i class="fas fa-chevron-left"></i>
</button>
<div id="shortsGrid" class="yt-shorts-grid">
<!-- Shorts loaded via JS -->
</div>
<button class="yt-shorts-arrow yt-shorts-right" onclick="scrollShorts('right')">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<!-- Videos Section --> <!-- Videos Section -->
<div id="videosSection" class="yt-section"> <div id="videosSection" class="yt-section">
<div class="yt-section-header"> <div class="yt-section-header" style="display:none;">
<h2><i class="fas fa-play-circle"></i> Videos</h2> <h2><i class="fas fa-play-circle"></i> Videos</h2>
</div> </div>
<div id="resultsArea" class="yt-video-grid"> <div id="resultsArea" class="yt-video-grid">
@ -356,8 +341,13 @@
border-radius: 12px; border-radius: 12px;
object-fit: cover; object-fit: cover;
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
opacity: 0;
transition: opacity 0.5s ease;
} }
.yt-short-thumb.loaded {
opacity: 1;
.yt-short-title { .yt-short-title {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@ -461,13 +451,14 @@
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'yt-short-card'; card.className = 'yt-short-card';
card.innerHTML = ` card.innerHTML = `
<img src="${video.thumbnail}" class="yt-short-thumb" loading="lazy"> <img data-src="${video.thumbnail}" class="yt-short-thumb">
<p class="yt-short-title">${escapeHtml(video.title)}</p> <p class="yt-short-title">${escapeHtml(video.title)}</p>
<p class="yt-short-views">${formatViews(video.view_count)} views</p> <p class="yt-short-views">${formatViews(video.view_count)} views</p>
`; `;
card.onclick = () => window.location.href = `/watch?v=${video.id}`; card.onclick = () => window.location.href = `/watch?v=${video.id}`;
shortsGrid.appendChild(card); shortsGrid.appendChild(card);
}); });
if (window.observeImages) window.observeImages();
} else { } else {
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">No shorts found</p>'; shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">No shorts found</p>';
} }

View file

@ -45,10 +45,6 @@
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu"> <button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<button class="yt-menu-btn" id="headerBackBtn" onclick="history.back()" aria-label="Back"
style="display:none;">
<i class="fas fa-arrow-left"></i>
</button>
<a href="/" class="yt-logo" <a href="/" class="yt-logo"
style="text-decoration: none; display: flex; align-items: center; gap: 4px;"> style="text-decoration: none; display: flex; align-items: center; gap: 4px;">
<span style="color: #ff0000; font-size: 24px;"><i class="fas fa-play-circle"></i></span> <span style="color: #ff0000; font-size: 24px;"><i class="fas fa-play-circle"></i></span>
@ -67,9 +63,10 @@
</div> </div>
<div class="yt-header-end"> <div class="yt-header-end">
<button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search"> <!-- Mobile Search Icon Removed - Search will be visible -->
<!-- <button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</button> </button> -->
{% if session.get('user_id') %} {% if session.get('user_id') %}
<div class="yt-avatar" title="{{ session.username }}"> <div class="yt-avatar" title="{{ session.username }}">
{{ session.username[0]|upper }} {{ session.username[0]|upper }}
@ -172,6 +169,11 @@
<main class="yt-main" id="mainContent"> <main class="yt-main" id="mainContent">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Floating Back Button (Mobile) -->
<button id="floatingBackBtn" class="yt-floating-back" onclick="history.back()" aria-label="Go Back">
<i class="fas fa-arrow-left"></i>
</button>
</div> </div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
@ -428,17 +430,7 @@
} }
// --- Back Button Logic --- // --- Back Button Logic ---
document.addEventListener('DOMContentLoaded', () => { // Back Button Logic Removed (Handled Server-Side)
const path = window.location.pathname;
const menuBtn = document.querySelector('.yt-menu-btn');
const backBtnHeader = document.getElementById('headerBackBtn');
// If not home, swap menu for back
if (path !== '/' && backBtnHeader) {
if (menuBtn) menuBtn.style.display = 'none';
backBtnHeader.style.display = 'flex';
}
});
</script> </script>
<style> <style>
/* Queue Drawer Styles */ /* Queue Drawer Styles */

View file

@ -8,11 +8,11 @@
<h3>Appearance</h3> <h3>Appearance</h3>
<p class="yt-settings-desc">Customize how KV-Tube looks on your device.</p> <p class="yt-settings-desc">Customize how KV-Tube looks on your device.</p>
<div class="yt-setting-row"> <div class="yt-setting-row">
<span>Dark Mode</span> <span>Theme Mode</span>
<label class="yt-switch"> <div class="yt-theme-selector">
<input type="checkbox" id="themeToggle" checked onchange="toggleTheme()"> <button type="button" class="yt-theme-btn" id="themeBtnLight" onclick="setTheme('light')">Light</button>
<span class="yt-slider round"></span> <button type="button" class="yt-theme-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
</label> </div>
</div> </div>
</div> </div>
@ -140,63 +140,36 @@
text-align: center; text-align: center;
} }
/* Toggle Switch */ /* Theme Selector */
.yt-setting-row { .yt-theme-selector {
display: flex; display: flex;
justify-content: space-between; gap: 12px;
align-items: center; background: var(--yt-bg-elevated);
padding: 8px 0; padding: 4px;
}
.yt-switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.yt-switch input {
opacity: 0;
width: 0;
height: 0;
}
.yt-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #666;
transition: .4s;
}
.yt-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
}
input:checked+.yt-slider {
background-color: var(--yt-accent-blue);
}
input:checked+.yt-slider:before {
transform: translateX(24px);
}
.yt-slider.round {
border-radius: 24px; border-radius: 24px;
} }
.yt-slider.round:before { .yt-theme-btn {
border-radius: 50%; flex: 1;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
color: var(--yt-text-secondary);
background: transparent;
transition: all 0.2s;
border: 2px solid transparent;
}
.yt-theme-btn:hover {
color: var(--yt-text-primary);
background: rgba(255, 255, 255, 0.05);
}
.yt-theme-btn.active {
background: var(--yt-bg-primary);
color: var(--yt-text-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
</style> </style>