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;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--yt-bg-primary);
/* Fix white bar issue */
}
body {
@ -66,6 +68,7 @@ body {
color: var(--yt-text-primary);
line-height: 1.4;
overflow-x: hidden;
min-height: 100vh;
}
a {
@ -194,7 +197,7 @@ button {
.yt-search-input {
flex: 1;
height: 40px;
background: var(--yt-bg-primary);
background: var(--yt-bg-secondary);
border: 1px solid var(--yt-border);
border-right: none;
border-radius: 20px 0 0 20px;
@ -371,6 +374,7 @@ button {
padding: 12px 0 24px;
overflow-x: auto;
scrollbar-width: none;
flex-wrap: nowrap;
-ms-overflow-style: none;
}
@ -430,7 +434,12 @@ button {
width: 100%;
height: 100%;
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 {
@ -819,11 +828,28 @@ button {
@media (max-width: 768px) {
.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 {
display: flex;
display: none;
/* Hide icon since bar is visible */
}
.yt-signin-text {
@ -831,8 +857,9 @@ button {
}
.yt-video-grid {
grid-template-columns: 1fr;
gap: 0;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 0 4px;
}
.yt-video-card {
@ -851,6 +878,10 @@ button {
.yt-categories {
padding: 8px 0 16px;
gap: 8px;
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
white-space: nowrap;
}
.yt-category-pill {
@ -1171,3 +1202,130 @@ button {
overflow: hidden;
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 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 ---
function initInfiniteScroll() {
const observer = new IntersectionObserver((entries) => {
@ -234,7 +259,7 @@ async function loadTrending(reset = true) {
card.innerHTML = `
<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>` : ''}
</div>
<div class="yt-video-details">
@ -255,6 +280,7 @@ async function loadTrending(reset = true) {
sectionDiv.appendChild(scrollContainer);
resultsArea.appendChild(sectionDiv);
});
if (window.observeImages) window.observeImages();
return;
}
@ -299,7 +325,7 @@ function displayResults(videos, append = false) {
card.style.width = '100%';
card.style.maxWidth = '200px';
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-views">${formatViews(video.view_count)} views</p>
`;
@ -308,7 +334,7 @@ function displayResults(videos, append = false) {
card.className = 'yt-video-card';
card.innerHTML = `
<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>` : ''}
</div>
<div class="yt-video-details">
@ -337,6 +363,8 @@ function displayResults(videos, append = false) {
});
resultsArea.appendChild(card);
});
if (window.observeImages) window.observeImages();
}
// Format view count (YouTube style)
@ -425,28 +453,51 @@ function initTheme() {
let savedTheme = localStorage.getItem('theme');
// If no saved preference, use Time of Day (Auto)
// Approximation: 6 AM to 6 PM is Light (Sunrise/Sunset)
if (!savedTheme) {
const hour = new Date().getHours();
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
const toggle = document.getElementById('themeToggle');
if (toggle) {
toggle.checked = savedTheme === 'dark';
function setTheme(theme, save = true) {
document.documentElement.setAttribute('data-theme', theme);
if (save) {
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() {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'light' ? 'dark' : 'light';
// Ensure theme persists on back navigation (BFCache)
window.addEventListener('pageshow', (event) => {
// Re-apply theme from storage to ensure it matches user preference
// even if page was restored from cache with old state
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setTheme(savedTheme, false);
} else {
initTheme();
}
});
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// Sync across tabs
window.addEventListener('storage', (event) => {
if (event.key === 'theme') {
setTheme(event.newValue, false);
}
});
// --- Profile Logic ---
async function updateProfile(e) {

View file

@ -42,26 +42,11 @@
<!-- 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 -->
<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>
</div>
<div id="resultsArea" class="yt-video-grid">
@ -356,39 +341,44 @@
border-radius: 12px;
object-fit: cover;
background: var(--yt-bg-secondary);
opacity: 0;
transition: opacity 0.5s ease;
}
.yt-short-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-short-thumb.loaded {
opacity: 1;
.yt-short-views {
font-size: 12px;
color: var(--yt-text-secondary);
margin-top: 4px;
}
@media (max-width: 768px) {
.yt-shorts-arrow {
display: none;
.yt-short-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-filter-bar {
padding: 0 10px;
top: 56px;
.yt-short-views {
font-size: 12px;
color: var(--yt-text-secondary);
margin-top: 4px;
}
.yt-sort-container {
/* Legacy override if needed */
@media (max-width: 768px) {
.yt-shorts-arrow {
display: none;
}
.yt-filter-bar {
padding: 0 10px;
top: 56px;
}
.yt-sort-container {
/* Legacy override if needed */
}
}
}
</style>
<script>
@ -461,13 +451,14 @@
const card = document.createElement('div');
card.className = 'yt-short-card';
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-views">${formatViews(video.view_count)} views</p>
`;
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
shortsGrid.appendChild(card);
});
if (window.observeImages) window.observeImages();
} else {
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">
<i class="fas fa-bars"></i>
</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"
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>
@ -67,9 +63,10 @@
</div>
<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>
</button>
</button> -->
{% if session.get('user_id') %}
<div class="yt-avatar" title="{{ session.username }}">
{{ session.username[0]|upper }}
@ -172,6 +169,11 @@
<main class="yt-main" id="mainContent">
{% block content %}{% endblock %}
</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>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
@ -428,17 +430,7 @@
}
// --- Back Button Logic ---
document.addEventListener('DOMContentLoaded', () => {
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';
}
});
// Back Button Logic Removed (Handled Server-Side)
</script>
<style>
/* Queue Drawer Styles */

View file

@ -8,11 +8,11 @@
<h3>Appearance</h3>
<p class="yt-settings-desc">Customize how KV-Tube looks on your device.</p>
<div class="yt-setting-row">
<span>Dark Mode</span>
<label class="yt-switch">
<input type="checkbox" id="themeToggle" checked onchange="toggleTheme()">
<span class="yt-slider round"></span>
</label>
<span>Theme Mode</span>
<div class="yt-theme-selector">
<button type="button" class="yt-theme-btn" id="themeBtnLight" onclick="setTheme('light')">Light</button>
<button type="button" class="yt-theme-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
</div>
</div>
</div>
@ -140,63 +140,36 @@
text-align: center;
}
/* Toggle Switch */
.yt-setting-row {
/* Theme Selector */
.yt-theme-selector {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.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 {
gap: 12px;
background: var(--yt-bg-elevated);
padding: 4px;
border-radius: 24px;
}
.yt-slider.round:before {
border-radius: 50%;
.yt-theme-btn {
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>