Major improvements: - Added responsive mobile view with optimized touch interactions - Implemented image caching to preload up to 2 images for faster transitions - Made images enter consistently from left side regardless of swipe direction - Enhanced swipe animations with reduced tilt and better fade-out effects - Reduced swipe sensitivity on mobile for better tap/swipe distinction - Removed headings and reduced history button height for more screen space - Added progressive fade effect during manual swipes - Sped up slide-in animations for snappier experience - Fixed multiple edge cases for better overall stability
636 lines
24 KiB
JavaScript
636 lines
24 KiB
JavaScript
document.addEventListener('DOMContentLoaded', function() {
|
|
const card = document.getElementById('current-card');
|
|
const lastActionText = document.getElementById('last-action');
|
|
const leftHint = document.querySelector('.left-hint');
|
|
const rightHint = document.querySelector('.right-hint');
|
|
const upHint = document.querySelector('.up-hint');
|
|
const downHint = document.querySelector('.down-hint');
|
|
const swipeContainer = document.querySelector('.swipe-container');
|
|
|
|
console.log('DOM Content Loaded - initializing app');
|
|
|
|
// Image cache for preloading
|
|
const imageCache = {
|
|
images: [], // Will store objects with {path, data, element}
|
|
maxSize: 2, // Cache up to 2 images
|
|
|
|
// Add an image to the cache
|
|
add: function(imageData) {
|
|
// Create a new Image element for preloading
|
|
const img = new Image();
|
|
img.src = imageData.path;
|
|
|
|
// Add to the cache
|
|
this.images.push({
|
|
path: imageData.path,
|
|
data: imageData,
|
|
element: img
|
|
});
|
|
|
|
console.log(`Added image to cache: ${imageData.path}`);
|
|
|
|
// Trim cache if it exceeds max size
|
|
if (this.images.length > this.maxSize) {
|
|
this.images.shift(); // Remove oldest image
|
|
}
|
|
},
|
|
|
|
// Get an image from cache if available
|
|
get: function(path) {
|
|
const cachedImage = this.images.find(img => img.path === path);
|
|
if (cachedImage) {
|
|
console.log(`Cache hit for: ${path}`);
|
|
// Remove this image from the cache since we're using it
|
|
this.images = this.images.filter(img => img.path !== path);
|
|
return cachedImage;
|
|
}
|
|
console.log(`Cache miss for: ${path}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Detect if we're on a mobile device
|
|
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
console.log('Mobile view detection:', isMobile ? 'mobile' : 'desktop');
|
|
|
|
// Apply mobile-specific behaviors
|
|
if (isMobile) {
|
|
console.log('Applying mobile-specific behaviors');
|
|
// Show swipe hints briefly on page load to educate users
|
|
setTimeout(() => {
|
|
showAllHints();
|
|
setTimeout(hideAllHints, 3000);
|
|
}, 1000);
|
|
}
|
|
|
|
// Important: Load the first image regardless of viewport size
|
|
console.log('Triggering initial image load');
|
|
// Wait a short moment to ensure all initialization is complete
|
|
setTimeout(() => {
|
|
loadNewImage();
|
|
}, 300);
|
|
|
|
// Adjust swipe container height to fill available space
|
|
function adjustSwipeContainerHeight() {
|
|
const viewportHeight = window.innerHeight;
|
|
const containerTop = swipeContainer.getBoundingClientRect().top;
|
|
const statusAreaHeight = document.querySelector('.status-area').offsetHeight;
|
|
const actionButtonsHeight = document.querySelector('.action-buttons') ?
|
|
document.querySelector('.action-buttons').offsetHeight : 0;
|
|
const footerSpace = 20; // Extra space for padding/margin
|
|
|
|
// Calculate available height
|
|
const availableHeight = viewportHeight - containerTop - statusAreaHeight -
|
|
actionButtonsHeight - footerSpace;
|
|
|
|
// Set minimum height to ensure it's usable
|
|
const minHeight = isMobile ? '60vh' : '400px';
|
|
swipeContainer.style.minHeight = `max(${availableHeight}px, ${minHeight})`;
|
|
|
|
console.log('Adjusted container height: ', swipeContainer.style.minHeight);
|
|
}
|
|
|
|
// Run on load and on resize
|
|
adjustSwipeContainerHeight();
|
|
window.addEventListener('resize', adjustSwipeContainerHeight);
|
|
|
|
// Make sure the first image loads after layout calculations are done
|
|
window.addEventListener('load', function() {
|
|
console.log('Window fully loaded - requesting first image');
|
|
loadNewImage();
|
|
});
|
|
|
|
// Add the animation class to the initial card
|
|
setTimeout(() => {
|
|
card.classList.add('new-card');
|
|
}, 100); // Small delay to ensure DOM is ready
|
|
|
|
// Modal elements
|
|
const modal = document.getElementById('fullscreen-modal');
|
|
const fullscreenImage = document.getElementById('fullscreen-image');
|
|
const closeModal = document.querySelector('.close-modal');
|
|
const modalResolution = document.getElementById('modal-resolution');
|
|
const modalFilename = document.getElementById('modal-filename');
|
|
const modalCreationDate = document.getElementById('modal-creation-date');
|
|
|
|
// Current image information
|
|
let currentImageInfo = null;
|
|
|
|
// Button event listeners
|
|
document.getElementById('btn-left').addEventListener('click', () => performSwipe('left'));
|
|
document.getElementById('btn-right').addEventListener('click', () => performSwipe('right'));
|
|
document.getElementById('btn-up').addEventListener('click', () => performSwipe('up'));
|
|
document.getElementById('btn-down').addEventListener('click', () => performSwipe('down'));
|
|
|
|
// Touch start time for distinguishing between swipe and tap
|
|
let touchStartTime = 0;
|
|
|
|
// Touch variables
|
|
let startX, startY, moveX, moveY;
|
|
let isDragging = false;
|
|
const swipeThreshold = 150; // Increased minimum distance for a swipe to be registered
|
|
let hasMoved = false; // Track if significant movement occurred
|
|
|
|
// Touch event handlers with passive: false for better mobile performance
|
|
card.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
card.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
card.addEventListener('touchend', handleTouchEnd, { passive: false });
|
|
|
|
// Mouse event handlers (for desktop testing)
|
|
card.addEventListener('mousedown', handleMouseDown, false);
|
|
document.addEventListener('mousemove', handleMouseMove, false);
|
|
document.addEventListener('mouseup', handleMouseUp, false);
|
|
|
|
// Click handler for viewing full-resolution image
|
|
card.addEventListener('click', handleCardClick);
|
|
|
|
// Close modal when clicking the close button
|
|
closeModal.addEventListener('click', () => {
|
|
modal.style.display = 'none';
|
|
});
|
|
|
|
// Close modal when clicking outside the image
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Close modal with escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.style.display === 'block') {
|
|
modal.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
function handleTouchStart(e) {
|
|
// Store the initial position and set the dragging flag
|
|
const touch = e.touches[0];
|
|
startX = touch.clientX;
|
|
startY = touch.clientY;
|
|
isDragging = true;
|
|
hasMoved = false; // Reset movement tracking
|
|
card.classList.add('swiping');
|
|
|
|
// Record touch start time to distinguish between tap and swipe
|
|
touchStartTime = new Date().getTime();
|
|
|
|
// Prevent default to avoid scrolling while swiping
|
|
e.preventDefault();
|
|
|
|
// Check if we're on mobile
|
|
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
|
|
// Show swipe hints on mobile
|
|
if (isMobile) {
|
|
showAllHints();
|
|
}
|
|
}
|
|
|
|
function handleTouchMove(e) {
|
|
if (!isDragging) return;
|
|
|
|
const touch = e.touches[0];
|
|
moveX = touch.clientX - startX;
|
|
moveY = touch.clientY - startY;
|
|
|
|
// Check if we've moved significantly
|
|
const absX = Math.abs(moveX);
|
|
const absY = Math.abs(moveY);
|
|
if (Math.max(absX, absY) > 20) {
|
|
hasMoved = true;
|
|
}
|
|
|
|
// Calculate a fade factor based on distance (further = more transparent)
|
|
const maxDistance = Math.max(window.innerWidth, window.innerHeight) * 0.4;
|
|
const distance = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
const fadeAmount = Math.min(0.7, distance / maxDistance);
|
|
|
|
// Apply transform with reduced rotation and opacity based on swipe distance
|
|
card.style.transform = `translate(${moveX}px, ${moveY}px) rotate(${moveX * 0.02}deg)`;
|
|
card.style.opacity = `${1 - fadeAmount}`;
|
|
|
|
// Show appropriate hint based on swipe direction
|
|
updateHints(moveX, moveY);
|
|
|
|
// Add visual feedback based on swipe direction
|
|
updateVisualFeedback(moveX, moveY);
|
|
|
|
// Prevent default to avoid scrolling while swiping
|
|
e.preventDefault();
|
|
}
|
|
|
|
// Add visual feedback based on swipe direction
|
|
function updateVisualFeedback(moveX, moveY) {
|
|
// Reset all borders
|
|
card.style.boxShadow = '0 10px 20px rgba(0, 0, 0, 0.2)';
|
|
|
|
const absX = Math.abs(moveX);
|
|
const absY = Math.abs(moveY);
|
|
|
|
// Only show feedback if we've moved enough
|
|
if (Math.max(absX, absY) < swipeThreshold / 2) return;
|
|
|
|
if (absX > absY) {
|
|
// Horizontal swipe
|
|
if (moveX > 0) {
|
|
// Right swipe - green glow
|
|
card.style.boxShadow = '0 0 20px 5px rgba(46, 213, 115, 0.7)';
|
|
} else {
|
|
// Left swipe - red glow
|
|
card.style.boxShadow = '0 0 20px 5px rgba(255, 71, 87, 0.7)';
|
|
}
|
|
} else {
|
|
// Vertical swipe
|
|
if (moveY > 0) {
|
|
// Down swipe - yellow glow
|
|
card.style.boxShadow = '0 0 20px 5px rgba(255, 165, 2, 0.7)';
|
|
} else {
|
|
// Up swipe - blue glow
|
|
card.style.boxShadow = '0 0 20px 5px rgba(30, 144, 255, 0.7)';
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd(e) {
|
|
if (!isDragging) return;
|
|
|
|
// Calculate touch duration
|
|
const touchEndTime = new Date().getTime();
|
|
const touchDuration = touchEndTime - touchStartTime;
|
|
|
|
// Determine if this was a tap (short touch with minimal movement)
|
|
const absX = Math.abs(moveX || 0);
|
|
const absY = Math.abs(moveY || 0);
|
|
// More generous tap detection - increased movement threshold to 30px
|
|
const isTap = touchDuration < 300 && Math.max(absX, absY) < 30;
|
|
|
|
isDragging = false;
|
|
|
|
if (isTap || !hasMoved) {
|
|
// This was a tap or minimal movement, not a swipe
|
|
resetCardPosition();
|
|
handleCardClick(e);
|
|
} else if (Math.max(absX, absY) > swipeThreshold && touchDuration > 100) {
|
|
// This was a swipe
|
|
if (absX > absY) {
|
|
// Horizontal swipe
|
|
if (moveX > 0) {
|
|
performSwipe('right');
|
|
} else {
|
|
performSwipe('left');
|
|
}
|
|
} else {
|
|
// Vertical swipe
|
|
if (moveY > 0) {
|
|
performSwipe('down');
|
|
} else {
|
|
performSwipe('up');
|
|
}
|
|
}
|
|
} else {
|
|
// Reset card position if swipe wasn't strong enough
|
|
resetCardPosition();
|
|
}
|
|
|
|
// Hide all hints
|
|
hideAllHints();
|
|
}
|
|
|
|
function handleMouseDown(e) {
|
|
// Store the initial position and set the dragging flag
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
isDragging = true;
|
|
card.classList.add('swiping');
|
|
|
|
// Prevent default to avoid text selection during drag
|
|
e.preventDefault();
|
|
}
|
|
|
|
function handleMouseMove(e) {
|
|
if (!isDragging) return;
|
|
|
|
moveX = e.clientX - startX;
|
|
moveY = e.clientY - startY;
|
|
|
|
// Apply transform to the card
|
|
card.style.transform = `translate(${moveX}px, ${moveY}px) rotate(${moveX * 0.1}deg)`;
|
|
|
|
// Show appropriate hint based on direction
|
|
updateHints(moveX, moveY);
|
|
}
|
|
|
|
function handleMouseUp(e) {
|
|
if (!isDragging) return;
|
|
|
|
// Determine if this was a click (minimal movement) or a swipe
|
|
const absX = Math.abs(moveX || 0);
|
|
const absY = Math.abs(moveY || 0);
|
|
|
|
isDragging = false;
|
|
|
|
if (Math.max(absX, absY) > swipeThreshold) {
|
|
if (absX > absY) {
|
|
// Horizontal swipe
|
|
if (moveX > 0) {
|
|
performSwipe('right');
|
|
} else {
|
|
performSwipe('left');
|
|
}
|
|
} else {
|
|
// Vertical swipe
|
|
if (moveY > 0) {
|
|
performSwipe('down');
|
|
} else {
|
|
performSwipe('up');
|
|
}
|
|
}
|
|
} else {
|
|
// Reset card position if swipe wasn't strong enough
|
|
resetCardPosition();
|
|
// We don't trigger click here because the card already has a click event listener
|
|
}
|
|
|
|
// Hide all hints
|
|
hideAllHints();
|
|
}
|
|
|
|
function updateHints(moveX, moveY) {
|
|
hideAllHints();
|
|
|
|
const absX = Math.abs(moveX);
|
|
const absY = Math.abs(moveY);
|
|
|
|
if (absX > absY) {
|
|
// Horizontal movement is dominant
|
|
if (moveX > 0) {
|
|
rightHint.style.opacity = '1';
|
|
} else {
|
|
leftHint.style.opacity = '1';
|
|
}
|
|
} else {
|
|
// Vertical movement is dominant
|
|
if (moveY > 0) {
|
|
downHint.style.opacity = '1';
|
|
} else {
|
|
upHint.style.opacity = '1';
|
|
}
|
|
}
|
|
}
|
|
|
|
function hideAllHints() {
|
|
leftHint.style.opacity = '0';
|
|
rightHint.style.opacity = '0';
|
|
upHint.style.opacity = '0';
|
|
downHint.style.opacity = '0';
|
|
}
|
|
|
|
function resetCardPosition() {
|
|
card.classList.remove('swiping');
|
|
card.style.transform = '';
|
|
card.style.opacity = '1';
|
|
}
|
|
|
|
function performSwipe(direction) {
|
|
// Add the appropriate swipe class
|
|
card.classList.add(`swipe-${direction}`);
|
|
|
|
// Start fading out immediately
|
|
card.style.opacity = '0.8';
|
|
|
|
// Update the last action text
|
|
lastActionText.textContent = `Last action: Swiped ${direction}`;
|
|
|
|
// Apply a more dramatic exit animation based on direction
|
|
if (direction === 'left') {
|
|
card.style.transform = 'translateX(-350%) rotate(-15deg)';
|
|
} else if (direction === 'right') {
|
|
card.style.transform = 'translateX(350%) rotate(15deg)';
|
|
} else if (direction === 'up') {
|
|
card.style.transform = 'translateY(-350%) rotate(5deg)';
|
|
} else if (direction === 'down') {
|
|
card.style.transform = 'translateY(350%) rotate(-5deg)';
|
|
}
|
|
|
|
// Record the selection in the database if we have a current image
|
|
if (currentImageInfo) {
|
|
recordSelection(currentImageInfo, direction);
|
|
}
|
|
|
|
// After animation completes, reset and load a new image
|
|
setTimeout(() => {
|
|
card.classList.remove(`swipe-${direction}`);
|
|
card.classList.remove('swiping');
|
|
card.style.transform = '';
|
|
|
|
// Position the card offscreen to the left (for consistent entry animation)
|
|
card.style.transform = 'translateX(-100%)';
|
|
card.style.opacity = '0';
|
|
|
|
// Load a new random image from our server
|
|
loadNewImage();
|
|
}, 200); // Reduced from 300ms to 200ms for faster transitions
|
|
}
|
|
|
|
// Function to record a selection in the database
|
|
function recordSelection(imageInfo, action) {
|
|
// Create the data to send
|
|
const data = {
|
|
path: imageInfo.path,
|
|
resolution: imageInfo.resolution,
|
|
action: action
|
|
};
|
|
|
|
// Send the data to the server
|
|
fetch('/record-selection', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error('Failed to record selection');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('Selection recorded:', data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error recording selection:', error);
|
|
});
|
|
}
|
|
|
|
// Function to prefetch images and store them in cache
|
|
function prefetchImages(count = 1) {
|
|
for (let i = 0; i < count; i++) {
|
|
// Use a small delay between requests to avoid overwhelming the server
|
|
setTimeout(() => {
|
|
console.log(`Prefetching image ${i+1} of ${count}`);
|
|
fetch('/random-image?t=' + new Date().getTime())
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Failed to fetch image for prefetching');
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
// Add the fetched image to our cache
|
|
imageCache.add(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error prefetching image:', error);
|
|
});
|
|
}, i * 200); // Stagger requests by 200ms
|
|
}
|
|
}
|
|
|
|
// Function to load a new image from our local server
|
|
function loadNewImage() {
|
|
console.log('loadNewImage called');
|
|
|
|
// Show loading state
|
|
const img = card.querySelector('img');
|
|
img.style.opacity = '0';
|
|
|
|
// Remove all animation classes to reset
|
|
card.classList.remove('new-card');
|
|
card.classList.remove('new-card-mobile');
|
|
|
|
// Ensure card is positioned off-screen to the left to start
|
|
// This guarantees entry from the left regardless of previous swipe direction
|
|
card.style.transform = 'translateX(-100%)';
|
|
card.style.transition = 'none'; // Disable transition when setting initial position
|
|
|
|
// Check if mobile view is active
|
|
const currentlyMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
console.log('Current view:', currentlyMobile ? 'mobile' : 'desktop');
|
|
|
|
// Try to get an image from cache first
|
|
if (imageCache.images.length > 0) {
|
|
console.log('Using cached image');
|
|
const cachedImage = imageCache.images.shift();
|
|
displayImage(cachedImage.data);
|
|
|
|
// Prefetch a new image to replace the one we just used
|
|
prefetchImages(1);
|
|
return;
|
|
}
|
|
|
|
console.log('No cached images available, fetching from server...');
|
|
// Fetch a random image from our API with a cache-busting parameter
|
|
fetch('/random-image?t=' + new Date().getTime())
|
|
.then(response => {
|
|
console.log('Fetch response received:', response.status);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch image: ' + response.status);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('Image data received:', data);
|
|
displayImage(data);
|
|
|
|
// After displaying the first image, prefetch more for the cache
|
|
if (imageCache.images.length < imageCache.maxSize) {
|
|
prefetchImages(imageCache.maxSize - imageCache.images.length);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching random image:', error);
|
|
// Handle the error (e.g., display an error message)
|
|
const statusElement = document.querySelector('.status-area p:first-child');
|
|
statusElement.textContent = 'Error: Failed to fetch image';
|
|
|
|
// Try again after a delay
|
|
setTimeout(() => {
|
|
console.log('Retrying image load after error...');
|
|
loadNewImage();
|
|
}, 3000);
|
|
});
|
|
}
|
|
|
|
// Function to display an image (from cache or fresh fetch)
|
|
function displayImage(data) {
|
|
// Store current image info
|
|
currentImageInfo = data;
|
|
|
|
// Extract filename from path
|
|
const pathParts = data.path.split('/');
|
|
const filename = pathParts[pathParts.length - 1];
|
|
currentImageInfo.filename = filename;
|
|
currentImageInfo.creation_date = data.creation_date || 'Unknown';
|
|
|
|
// Check if mobile view is active
|
|
const currentlyMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
|
|
// Get the image element
|
|
const img = card.querySelector('img');
|
|
|
|
// Set the image source
|
|
img.src = data.path;
|
|
|
|
// Set up the onload handler
|
|
img.onload = function() {
|
|
console.log('Image loaded successfully');
|
|
|
|
// Force a reflow to ensure animation works
|
|
void card.offsetWidth;
|
|
|
|
// Re-enable transitions for the animation with faster timing
|
|
card.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
|
|
|
|
// Make the image visible
|
|
img.style.opacity = '1';
|
|
|
|
// Different animation approach for mobile vs desktop
|
|
if (currentlyMobile) {
|
|
// Simple fade-in for mobile - more reliable
|
|
card.style.transform = 'translateX(0)';
|
|
card.classList.add('new-card-mobile');
|
|
} else {
|
|
// Slide-in animation for desktop
|
|
card.classList.add('new-card');
|
|
}
|
|
};
|
|
|
|
// Add error handling for image loading
|
|
img.onerror = function() {
|
|
console.error('Failed to load image:', data.path);
|
|
img.src = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22400%22%20height%3D%22400%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%22400%22%20height%3D%22400%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Ctext%20x%3D%22200%22%20y%3D%22200%22%20font-size%3D%2220%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20fill%3D%22%23999%22%3EError Loading Image%3C%2Ftext%3E%3C%2Fsvg%3E';
|
|
img.style.opacity = '1';
|
|
|
|
// Try loading a new image after a delay
|
|
setTimeout(loadNewImage, 2000);
|
|
};
|
|
|
|
// Update status with resolution info
|
|
const statusElement = document.querySelector('.status-area p:first-child');
|
|
statusElement.textContent = `Current resolution: ${data.resolution}`;
|
|
}
|
|
|
|
// Function to handle card click for viewing full-resolution image
|
|
function handleCardClick(e) {
|
|
// Only process click if we have image info and we're not in the middle of a swipe
|
|
if (!currentImageInfo || card.classList.contains('swiping')) return;
|
|
|
|
// Prevent click from propagating (important for touch devices)
|
|
if (e) e.stopPropagation();
|
|
|
|
// Set the full-resolution image source
|
|
fullscreenImage.src = currentImageInfo.path;
|
|
|
|
// Update modal info
|
|
modalResolution.textContent = `Resolution: ${currentImageInfo.resolution}`;
|
|
modalFilename.textContent = `Filename: ${currentImageInfo.filename || 'Unknown'}`;
|
|
modalCreationDate.textContent = `Creation Date: ${currentImageInfo.creation_date || 'Unknown'}`;
|
|
|
|
// Display the modal
|
|
modal.style.display = 'block';
|
|
}
|
|
|
|
// Load initial image
|
|
loadNewImage();
|
|
});
|