Add advanced gallery features: lazy loading, sorting, and loading animations
- Implement lazy loading for video thumbnails using Intersection Observer - Only load thumbnails when they enter viewport (50px margin) - Smooth fade-in transition when thumbnails load - Significantly reduces initial page load time - Add shimmer loading animation for thumbnail containers - Gradient shimmer effect while videos are loading - Automatic removal once content loads - Professional loading state feedback - Add video sorting functionality - Sort by: Newest/Oldest, Name (A-Z/Z-A), Size (Largest/Smallest) - Client-side sorting for instant results - Maintains lazy loading when re-rendering sorted videos - Improve error handling - Graceful fallback with placeholder icon for failed thumbnails - Better UX when videos fail to load Performance improvements especially noticeable with large video collections. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
50e44f7c24
commit
2d71a13621
|
|
@ -610,6 +610,30 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
||||||
header { text-align: center; margin-bottom: 40px; padding: 20px; }
|
header { text-align: center; margin-bottom: 40px; padding: 20px; }
|
||||||
h1 { font-size: 2.5rem; margin-bottom: 10px; }
|
h1 { font-size: 2.5rem; margin-bottom: 10px; }
|
||||||
.subtitle { color: #aaa; font-size: 1.1rem; }
|
.subtitle { color: #aaa; font-size: 1.1rem; }
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sort-select {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.sort-select:hover {
|
||||||
|
border-color: #3ea6ff;
|
||||||
|
}
|
||||||
|
.sort-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3ea6ff;
|
||||||
|
}
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
|
@ -631,15 +655,30 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
background: #000;
|
background: linear-gradient(90deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.video-thumbnail.loaded {
|
||||||
|
animation: none;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
.video-thumbnail video {
|
.video-thumbnail video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.video-thumbnail video.loaded {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.play-overlay {
|
.play-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -706,11 +745,33 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
||||||
<header>
|
<header>
|
||||||
<h1>🎥 Video Gallery</h1>
|
<h1>🎥 Video Gallery</h1>
|
||||||
<p class="subtitle">${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available</p>
|
<p class="subtitle">${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available</p>
|
||||||
|
${shareableVideos.length > 0 ? `
|
||||||
|
<div class="controls">
|
||||||
|
<select class="sort-select" id="sortSelect" onchange="sortVideos(this.value)">
|
||||||
|
<option value="newest">Newest First</option>
|
||||||
|
<option value="oldest">Oldest First</option>
|
||||||
|
<option value="name-asc">Name (A-Z)</option>
|
||||||
|
<option value="name-desc">Name (Z-A)</option>
|
||||||
|
<option value="size-desc">Largest First</option>
|
||||||
|
<option value="size-asc">Smallest First</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</header>
|
</header>
|
||||||
<div class="video-grid">
|
<div class="video-grid" id="videoGrid">
|
||||||
${emptyState}
|
${emptyState}
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// Store videos for sorting
|
||||||
|
let allVideos = ${JSON.stringify(shareableVideos.map(v => ({
|
||||||
|
key: v.key,
|
||||||
|
size: v.size,
|
||||||
|
uploaded: v.uploaded,
|
||||||
|
sizeInMB: (v.size / (1024 * 1024)).toFixed(2),
|
||||||
|
uploadDate: new Date(v.uploaded).toLocaleDateString(),
|
||||||
|
encodedKey: encodeURIComponent(v.key)
|
||||||
|
})))};
|
||||||
|
|
||||||
function playVideo(encodedKey) {
|
function playVideo(encodedKey) {
|
||||||
window.location.href = '/watch/' + encodedKey;
|
window.location.href = '/watch/' + encodedKey;
|
||||||
}
|
}
|
||||||
|
|
@ -720,28 +781,130 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
||||||
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load thumbnails on page load
|
function sortVideos(sortBy) {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
const sorted = [...allVideos];
|
||||||
|
|
||||||
|
switch(sortBy) {
|
||||||
|
case 'newest':
|
||||||
|
sorted.sort((a, b) => new Date(b.uploaded) - new Date(a.uploaded));
|
||||||
|
break;
|
||||||
|
case 'oldest':
|
||||||
|
sorted.sort((a, b) => new Date(a.uploaded) - new Date(b.uploaded));
|
||||||
|
break;
|
||||||
|
case 'name-asc':
|
||||||
|
sorted.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
break;
|
||||||
|
case 'name-desc':
|
||||||
|
sorted.sort((a, b) => b.key.localeCompare(a.key));
|
||||||
|
break;
|
||||||
|
case 'size-desc':
|
||||||
|
sorted.sort((a, b) => b.size - a.size);
|
||||||
|
break;
|
||||||
|
case 'size-asc':
|
||||||
|
sorted.sort((a, b) => a.size - b.size);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVideos(sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVideos(videos) {
|
||||||
|
const grid = document.getElementById('videoGrid');
|
||||||
|
grid.innerHTML = videos.map(obj => \`
|
||||||
|
<div class="video-item">
|
||||||
|
<div class="video-thumbnail" onclick="playVideo('\${obj.encodedKey}')">
|
||||||
|
<video id="thumb-\${obj.encodedKey}" preload="metadata" muted>
|
||||||
|
<source src="/\${obj.key}#t=0.5" type="\${getContentType(obj.key)}">
|
||||||
|
</video>
|
||||||
|
<div class="play-overlay">
|
||||||
|
<div class="play-button">▶</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-info">
|
||||||
|
<h3 onclick="playVideo('\${obj.encodedKey}')" style="cursor: pointer;">\${obj.key}</h3>
|
||||||
|
<p>Size: \${obj.sizeInMB} MB | Uploaded: \${obj.uploadDate}</p>
|
||||||
|
<div class="button-group">
|
||||||
|
<button onclick="playVideo('\${obj.encodedKey}')">Watch</button>
|
||||||
|
<button onclick="copyLink('\${obj.key}')">Copy Link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
\`).join('\\n');
|
||||||
|
|
||||||
|
// Re-initialize lazy loading for new thumbnails
|
||||||
|
initLazyLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentType(filename) {
|
||||||
|
const ext = filename.split('.').pop().toLowerCase();
|
||||||
|
const types = {
|
||||||
|
'mp4': 'video/mp4',
|
||||||
|
'mkv': 'video/x-matroska',
|
||||||
|
'mov': 'video/quicktime',
|
||||||
|
'avi': 'video/x-msvideo',
|
||||||
|
'webm': 'video/webm',
|
||||||
|
'flv': 'video/x-flv',
|
||||||
|
'wmv': 'video/x-ms-wmv'
|
||||||
|
};
|
||||||
|
return types[ext] || 'video/mp4';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load thumbnails using Intersection Observer
|
||||||
|
function initLazyLoading() {
|
||||||
const videos = document.querySelectorAll('.video-thumbnail video');
|
const videos = document.querySelectorAll('.video-thumbnail video');
|
||||||
videos.forEach(video => {
|
|
||||||
// Set up error handling for videos that fail to load
|
|
||||||
video.addEventListener('error', function() {
|
|
||||||
// Hide the video element and show a placeholder
|
|
||||||
const thumbnail = this.closest('.video-thumbnail');
|
|
||||||
const overlay = thumbnail.querySelector('.play-overlay');
|
|
||||||
this.style.display = 'none';
|
|
||||||
thumbnail.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)';
|
|
||||||
|
|
||||||
// Add a video icon placeholder
|
// Create intersection observer for lazy loading
|
||||||
const icon = document.createElement('div');
|
const observerOptions = {
|
||||||
icon.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 3rem; opacity: 0.3;';
|
root: null,
|
||||||
icon.textContent = '🎬';
|
rootMargin: '50px',
|
||||||
thumbnail.insertBefore(icon, overlay);
|
threshold: 0.01
|
||||||
|
};
|
||||||
|
|
||||||
|
const videoObserver = new IntersectionObserver((entries, observer) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const video = entry.target;
|
||||||
|
|
||||||
|
// Set up error handling for videos that fail to load
|
||||||
|
video.addEventListener('error', function() {
|
||||||
|
// Hide the video element and show a placeholder
|
||||||
|
const thumbnail = this.closest('.video-thumbnail');
|
||||||
|
const overlay = thumbnail.querySelector('.play-overlay');
|
||||||
|
this.style.display = 'none';
|
||||||
|
thumbnail.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)';
|
||||||
|
|
||||||
|
// Add a video icon placeholder
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 3rem; opacity: 0.3;';
|
||||||
|
icon.textContent = '🎬';
|
||||||
|
thumbnail.insertBefore(icon, overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show video when loaded
|
||||||
|
video.addEventListener('loadeddata', function() {
|
||||||
|
this.classList.add('loaded');
|
||||||
|
const thumbnail = this.closest('.video-thumbnail');
|
||||||
|
thumbnail.classList.add('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to load the video
|
||||||
|
video.load();
|
||||||
|
|
||||||
|
// Stop observing this video
|
||||||
|
observer.unobserve(video);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
// Try to load the video
|
// Observe all video elements
|
||||||
video.load();
|
videos.forEach(video => {
|
||||||
|
videoObserver.observe(video);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initLazyLoading();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue