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; }
|
||||
h1 { font-size: 2.5rem; margin-bottom: 10px; }
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
|
|
@ -631,15 +655,30 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
|||
position: relative;
|
||||
width: 100%;
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.video-thumbnail.loaded {
|
||||
animation: none;
|
||||
background: #000;
|
||||
}
|
||||
.video-thumbnail video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.video-thumbnail video.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
|
|
@ -706,11 +745,33 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
|||
<header>
|
||||
<h1>🎥 Video Gallery</h1>
|
||||
<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>
|
||||
<div class="video-grid">
|
||||
<div class="video-grid" id="videoGrid">
|
||||
${emptyState}
|
||||
</div>
|
||||
<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) {
|
||||
window.location.href = '/watch/' + encodedKey;
|
||||
}
|
||||
|
|
@ -720,28 +781,130 @@ async function handlePublicGallery(bucket, kv, corsHeaders) {
|
|||
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
||||
}
|
||||
|
||||
// Load thumbnails on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function sortVideos(sortBy) {
|
||||
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');
|
||||
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
|
||||
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);
|
||||
// Create intersection observer for lazy loading
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '50px',
|
||||
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
|
||||
video.load();
|
||||
// Observe all video elements
|
||||
videos.forEach(video => {
|
||||
videoObserver.observe(video);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initLazyLoading();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Reference in New Issue