diff --git a/app.py b/app.py
index 3e12535..8a7a685 100644
--- a/app.py
+++ b/app.py
@@ -7,6 +7,8 @@ import urllib.parse
import sqlite3
import time
import datetime
+import zipfile
+import io
# Path to the image directory
IMAGE_DIR = "/mnt/secret-items/sd-outputs/Sorted/Images/Portrait"
@@ -14,21 +16,32 @@ IMAGE_DIR = "/mnt/secret-items/sd-outputs/Sorted/Images/Portrait"
# Database file path
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image_selections.db")
-# Initialize database
+# Initialize the database
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
+ # Check if orientation column exists
+ cursor.execute("PRAGMA table_info(image_selections)")
+ columns = [column[1] for column in cursor.fetchall()]
+
# Create table if it doesn't exist
- cursor.execute('''
- CREATE TABLE IF NOT EXISTS image_selections (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- image_path TEXT NOT NULL,
- resolution TEXT NOT NULL,
- action TEXT NOT NULL,
- timestamp INTEGER NOT NULL
- )
- ''')
+ if 'image_selections' not in [table[0] for table in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]:
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS image_selections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ image_path TEXT NOT NULL,
+ resolution TEXT NOT NULL,
+ action TEXT NOT NULL,
+ timestamp INTEGER NOT NULL,
+ orientation TEXT
+ )
+ ''')
+ print("Created new image_selections table with orientation column")
+ elif 'orientation' not in columns:
+ # Add orientation column if it doesn't exist
+ cursor.execute('ALTER TABLE image_selections ADD COLUMN orientation TEXT')
+ print("Added orientation column to existing table")
conn.commit()
conn.close()
@@ -36,14 +49,25 @@ def init_db():
# Add a selection to the database
def add_selection(image_path, resolution, action):
+ # Determine if image is portrait or landscape
+ orientation = "unknown"
+ try:
+ from PIL import Image
+ full_path = os.path.join(IMAGE_DIR, image_path.replace('/images/', ''))
+ with Image.open(full_path) as img:
+ width, height = img.size
+ orientation = "portrait" if height > width else "landscape" if width > height else "square"
+ except Exception as e:
+ print(f"DEBUG ERROR determining image orientation: {str(e)}")
+
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Insert the selection
cursor.execute('''
- INSERT INTO image_selections (image_path, resolution, action, timestamp)
- VALUES (?, ?, ?, ?)
- ''', (image_path, resolution, action, int(time.time())))
+ INSERT INTO image_selections (image_path, resolution, action, timestamp, orientation)
+ VALUES (?, ?, ?, ?, ?)
+ ''', (image_path, resolution, action, int(time.time()), orientation))
conn.commit()
conn.close()
@@ -69,6 +93,23 @@ def get_selections():
item = {}
for key in row.keys():
item[key] = row[key]
+
+ # Ensure orientation exists
+ if 'orientation' not in item or not item['orientation']:
+ try:
+ # Try to determine orientation if not in database
+ from PIL import Image
+ image_path = item['image_path']
+ if image_path.startswith('/images/'):
+ image_path = image_path[8:]
+ full_path = os.path.join(IMAGE_DIR, image_path)
+ with Image.open(full_path) as img:
+ width, height = img.size
+ item['orientation'] = "portrait" if height > width else "landscape" if width > height else "square"
+ except Exception as e:
+ print(f"DEBUG ERROR determining missing orientation: {str(e)}")
+ item['orientation'] = "unknown"
+
results.append(item)
print(f"DEBUG: Converted {len(results)} rows to dictionaries")
@@ -81,6 +122,25 @@ def get_selections():
# Return empty list on error to prevent client from hanging
return []
+# Get a list of all image paths that have already been actioned
+def get_actioned_images():
+ try:
+ conn = sqlite3.connect(DB_PATH)
+ cursor = conn.cursor()
+
+ cursor.execute('''
+ SELECT DISTINCT image_path FROM image_selections
+ ''')
+
+ rows = cursor.fetchall()
+ actioned_images = [row[0] for row in rows]
+
+ conn.close()
+ return actioned_images
+ except Exception as e:
+ print(f"DEBUG ERROR in get_actioned_images(): {str(e)}")
+ return []
+
# Update a selection in the database
def update_selection(selection_id, action):
conn = sqlite3.connect(DB_PATH)
@@ -144,38 +204,47 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def do_GET(self):
- # Parse the URL path
- parsed_path = urllib.parse.urlparse(self.path)
- path = parsed_path.path
+ # Parse the URL
+ parsed_url = urllib.parse.urlparse(self.path)
+ path = parsed_url.path
- # Serve static files
- if path == "/" or path == "":
- self.serve_file("index.html")
- elif path == "/random-image":
+ # Handle different paths
+ if path == '/':
+ self.serve_file('index.html', 'text/html')
+ elif path == '/history':
+ self.serve_file('history.html', 'text/html')
+ elif path == '/styles.css':
+ self.serve_file('styles.css', 'text/css')
+ elif path == '/script.js':
+ self.serve_file('script.js', 'application/javascript')
+ elif path == '/random-image':
self.serve_random_image()
- elif path == "/image-resolutions":
- self.serve_resolutions()
- elif path == "/selections":
+ elif path == '/selections':
self.serve_selections()
- elif path.startswith("/images/"):
- # Extract the image path from the URL
- image_path = path[8:] # Remove "/images/" prefix
- self.serve_image(image_path)
+ elif path.startswith('/images/'):
+ self.serve_image(path[8:])
+ elif path.startswith('/download-selected'):
+ self.handle_download_selected()
else:
- # Serve other static files
- if path.startswith("/"):
+ # Try to serve as a static file
+ if path.startswith('/'):
path = path[1:] # Remove leading slash
- self.serve_file(path)
+ try:
+ self.serve_file(path)
+ except:
+ self.send_error(404, "File not found")
- def serve_file(self, file_path):
+ def serve_file(self, file_path, content_type=None):
try:
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), file_path), 'rb') as file:
content = file.read()
self.send_response(200)
- # Set the content type based on file extension
- content_type, _ = mimetypes.guess_type(file_path)
+ # Set the content type based on parameter or guess from file extension
+ if not content_type:
+ content_type, _ = mimetypes.guess_type(file_path)
+
if content_type:
self.send_header('Content-type', content_type)
else:
@@ -214,23 +283,53 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
self.send_error(404, f"Image not found: {image_path}")
def serve_random_image(self):
+ print("DEBUG: serve_random_image() called")
try:
+ # Get list of already actioned images
+ actioned_images = get_actioned_images()
+ print(f"DEBUG: Found {len(actioned_images)} already actioned images")
+
# Get all resolution directories
resolutions = [d for d in os.listdir(IMAGE_DIR) if os.path.isdir(os.path.join(IMAGE_DIR, d))]
- # Choose a random resolution
- resolution = random.choice(resolutions)
- resolution_dir = os.path.join(IMAGE_DIR, resolution)
-
- # Get all images in the selected resolution directory
- images = [f for f in os.listdir(resolution_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
-
- if not images:
- self.send_error(404, "No images found in the selected resolution directory")
- return
+ # Try to find an unactioned image
+ max_attempts = 20 # Limit the number of attempts to find an unactioned image
+ for attempt in range(max_attempts):
+ # Choose a random resolution
+ resolution = random.choice(resolutions)
+ resolution_dir = os.path.join(IMAGE_DIR, resolution)
- # Choose a random image
- image_name = random.choice(images)
+ # Get all images in the selected resolution directory
+ images = [f for f in os.listdir(resolution_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
+
+ if not images:
+ continue # Try another resolution if this one has no images
+
+ # Filter out already actioned images
+ unactioned_images = [img for img in images if f"{resolution}/{img}" not in actioned_images]
+
+ # If we have unactioned images, choose one randomly
+ if unactioned_images:
+ image_name = random.choice(unactioned_images)
+ print(f"DEBUG: Found unactioned image: {resolution}/{image_name}")
+ break
+ elif attempt == max_attempts - 1:
+ # If we've tried max_attempts times and still haven't found an unactioned image,
+ # just choose any image
+ image_name = random.choice(images)
+ print(f"DEBUG: No unactioned images found after {max_attempts} attempts, using: {resolution}/{image_name}")
+ else:
+ # This will only execute if the for loop completes without a break
+ # Choose any random image as fallback
+ resolution = random.choice(resolutions)
+ resolution_dir = os.path.join(IMAGE_DIR, resolution)
+ images = [f for f in os.listdir(resolution_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
+ if not images:
+ self.send_error(404, "No images found in any resolution directory")
+ return
+ image_name = random.choice(images)
+ print(f"DEBUG: Using fallback random image: {resolution}/{image_name}")
+
image_path = f"{resolution}/{image_name}"
full_image_path = os.path.join(IMAGE_DIR, image_path)
@@ -243,12 +342,23 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
print(f"DEBUG ERROR getting file creation time: {str(e)}")
creation_date = "Unknown"
+ # Determine if image is portrait or landscape
+ try:
+ from PIL import Image
+ with Image.open(full_image_path) as img:
+ width, height = img.size
+ orientation = "portrait" if height > width else "landscape" if width > height else "square"
+ except Exception as e:
+ print(f"DEBUG ERROR determining image orientation: {str(e)}")
+ orientation = "unknown"
+
# Return the image path as JSON
response = {
'path': f"/images/{image_path}",
'resolution': resolution,
'filename': image_name,
- 'creation_date': creation_date
+ 'creation_date': creation_date,
+ 'orientation': orientation
}
self.send_response(200)
@@ -322,8 +432,12 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
self.handle_update_selection()
elif path == "/delete-selection":
self.handle_delete_selection()
- elif path == "/reset-database":
+ elif self.path == '/reset-database':
self.handle_reset_database()
+ return
+ elif self.path.startswith('/download-selected'):
+ self.handle_download_selected()
+ return
else:
self.send_error(404, "Not found")
@@ -443,26 +557,67 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
def handle_reset_database(self):
try:
- print("DEBUG: Handling reset database request")
- # Reset the database
- rows_affected = reset_database()
-
- # Return success response
- response = {
- 'success': True,
- 'message': f"Database reset: {rows_affected} selections deleted"
- }
-
+ reset_database()
self.send_response(200)
self.send_header('Content-type', 'application/json')
- self._set_cors_headers()
+ self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
- self.wfile.write(json.dumps(response).encode())
- print("DEBUG: Reset database response sent")
+ self.wfile.write(json.dumps({'success': True, 'message': 'Database reset successfully'}).encode())
except Exception as e:
- print(f"DEBUG ERROR in handle_reset_database(): {str(e)}")
- self.send_error(500, f"Error resetting database: {str(e)}")
-
+ print(f"DEBUG ERROR in handle_reset_database: {str(e)}")
+ self.send_response(500)
+ self.send_header('Content-type', 'application/json')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ self.wfile.write(json.dumps({'success': False, 'message': f'Error: {str(e)}'}).encode())
+
+ def handle_download_selected(self):
+ try:
+ # Parse the query parameters to get the selected image paths
+ query_components = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
+ image_paths = query_components.get('paths', [])
+
+ if not image_paths:
+ self.send_response(400)
+ self.send_header('Content-type', 'application/json')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ self.wfile.write(json.dumps({'success': False, 'message': 'No image paths provided'}).encode())
+ return
+
+ # Create a zip file in memory
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False) as zip_file:
+ for path in image_paths:
+ # Remove the /images/ prefix
+ if path.startswith('/images/'):
+ path = path[8:]
+
+ full_path = os.path.join(IMAGE_DIR, path)
+ if os.path.exists(full_path):
+ # Add the file to the zip with just the filename (no directory structure)
+ filename = os.path.basename(path)
+ zip_file.write(full_path, filename)
+
+ # Seek to the beginning of the buffer
+ zip_buffer.seek(0)
+
+ # Send the zip file as a response
+ self.send_response(200)
+ self.send_header('Content-type', 'application/zip')
+ self.send_header('Content-Disposition', 'attachment; filename="selected_images.zip"')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ self.wfile.write(zip_buffer.getvalue())
+
+ except Exception as e:
+ print(f"DEBUG ERROR in handle_download_selected: {str(e)}")
+ self.send_response(500)
+ self.send_header('Content-type', 'application/json')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ self.wfile.write(json.dumps({'success': False, 'message': f'Error: {str(e)}'}).encode())
+
def do_OPTIONS(self):
# Handle preflight requests for CORS
self.send_response(200)
diff --git a/history.html b/history.html
index dd8ebf9..eeb24df 100644
--- a/history.html
+++ b/history.html
@@ -24,45 +24,6 @@
gap: 10px;
}
- .reset-button {
- padding: 8px 15px;
- background-color: #ff4757;
- color: white;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- text-decoration: none;
- }
-
- .reset-button:hover {
- background-color: #ff6b81;
- }
-
- .reset-modal-buttons {
- display: flex;
- justify-content: center;
- gap: 15px;
- margin-top: 20px;
- }
-
- .danger-button {
- padding: 10px 20px;
- background-color: #ff4757;
- color: white;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- }
-
- .cancel-button {
- padding: 10px 20px;
- background-color: #ddd;
- color: #333;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- }
-
.back-button {
padding: 8px 15px;
background-color: #333;
@@ -73,6 +34,95 @@
text-decoration: none;
}
+ .filter-container {
+ margin-bottom: 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ background-color: #f5f5f5;
+ padding: 15px;
+ border-radius: 8px;
+ }
+
+ .filter-section {
+ flex: 1;
+ min-width: 200px;
+ }
+
+ .filter-section h4 {
+ margin-top: 0;
+ margin-bottom: 10px;
+ color: #333;
+ }
+
+ .filter-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .filter-btn {
+ padding: 6px 12px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ background-color: #ddd;
+ color: #333;
+ transition: all 0.2s;
+ }
+
+ .filter-btn.active {
+ background-color: #2c3e50;
+ color: white;
+ }
+
+ .resolution-select {
+ width: 100%;
+ padding: 6px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ }
+
+ .action-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+ }
+
+ .reset-btn {
+ padding: 8px 15px;
+ background-color: #ff4757;
+ color: white;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .select-btn {
+ padding: 8px 15px;
+ background-color: #3498db;
+ color: white;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .download-btn {
+ padding: 8px 15px;
+ background-color: #2ecc71;
+ color: white;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .download-btn:disabled {
+ background-color: #95a5a6;
+ cursor: not-allowed;
+ }
+
.selection-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -86,6 +136,25 @@
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
background-color: white;
position: relative;
+ transition: transform 0.2s, box-shadow 0.2s;
+ }
+
+ .selection-item.selected {
+ box-shadow: 0 0 0 3px #3498db, 0 3px 10px rgba(0, 0, 0, 0.2);
+ transform: translateY(-3px);
+ }
+
+ .selection-checkbox-container {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 5;
+ }
+
+ .selection-checkbox {
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
}
.selection-item img {
@@ -166,70 +235,59 @@
background-color: #ffa502;
}
- .filter-controls {
- margin-bottom: 20px;
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
+ .modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1000;
+ overflow: auto;
}
- .filter-button {
+ .modal-content {
+ background-color: white;
+ margin: 10% auto;
+ padding: 20px;
+ width: 80%;
+ max-width: 600px;
+ border-radius: 10px;
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
+ position: relative;
+ }
+
+ .close-modal {
+ position: absolute;
+ top: 10px;
+ right: 15px;
+ font-size: 24px;
+ cursor: pointer;
+ color: #666;
+ }
+
+ .action-btn {
padding: 8px 15px;
border: none;
border-radius: 5px;
- cursor: pointer;
+ color: white;
font-weight: bold;
- opacity: 0.7;
- transition: opacity 0.3s;
- }
-
- .filter-button.active {
- opacity: 1;
- box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
- }
-
- .filter-all {
- background-color: #ddd;
- color: #333;
- }
-
- .filter-left {
- background-color: #ff4757;
- color: white;
- }
-
- .filter-right {
- background-color: #2ed573;
- color: white;
- }
-
- .filter-up {
- background-color: #1e90ff;
- color: white;
- }
-
- .filter-down {
- background-color: #ffa502;
- color: white;
+ cursor: pointer;
+ margin: 0 5px;
}
.no-selections {
text-align: center;
padding: 30px;
- background-color: #f5f5f5;
- border-radius: 10px;
color: #666;
- }
-
- .timestamp {
- color: #777;
- font-size: 0.8rem;
+ font-size: 1.2rem;
}
-
+
×
Change Action
@@ -250,8 +308,7 @@
@@ -268,12 +325,42 @@
-
-
-
-
-
-
+
+
+
Action
+
+
+
+
+
+
+
+
+
+
+
Orientation
+
+
+
+
+
+
+
+
+
+
Resolution
+
+
+
+
+
+
+
+
+
@@ -284,172 +371,158 @@
diff --git a/index.html b/index.html
index ef4746a..ad1fa9c 100644
--- a/index.html
+++ b/index.html
@@ -9,8 +9,6 @@
@@ -30,10 +28,10 @@
-
-
-
-
+
+
+
+
@@ -43,7 +41,7 @@
Left: Discard
Right: Keep
Up: Favorite
-
Down: Review Later
+
Down: Review
diff --git a/script.js b/script.js
index df96723..55bcd4b 100644
--- a/script.js
+++ b/script.js
@@ -5,6 +5,105 @@ document.addEventListener('DOMContentLoaded', function() {
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');
@@ -29,12 +128,13 @@ document.addEventListener('DOMContentLoaded', function() {
// Touch variables
let startX, startY, moveX, moveY;
let isDragging = false;
- const swipeThreshold = 100; // Minimum distance for a swipe to be registered
+ const swipeThreshold = 150; // Increased minimum distance for a swipe to be registered
+ let hasMoved = false; // Track if significant movement occurred
- // Touch event handlers
- card.addEventListener('touchstart', handleTouchStart, false);
- card.addEventListener('touchmove', handleTouchMove, false);
- card.addEventListener('touchend', handleTouchEnd, false);
+ // 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);
@@ -64,26 +164,92 @@ document.addEventListener('DOMContentLoaded', function() {
});
function handleTouchStart(e) {
- startX = e.touches[0].clientX;
- startY = e.touches[0].clientY;
+ // 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');
- // Store touch start time to differentiate between swipe and tap
+ // 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;
- moveX = e.touches[0].clientX - startX;
- moveY = e.touches[0].clientY - startY;
+ const touch = e.touches[0];
+ moveX = touch.clientX - startX;
+ moveY = touch.clientY - startY;
- // Apply transform to the card
- card.style.transform = `translate(${moveX}px, ${moveY}px) rotate(${moveX * 0.1}deg)`;
+ // Check if we've moved significantly
+ const absX = Math.abs(moveX);
+ const absY = Math.abs(moveY);
+ if (Math.max(absX, absY) > 20) {
+ hasMoved = true;
+ }
- // Show appropriate hint based on direction
+ // 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) {
@@ -96,15 +262,16 @@ document.addEventListener('DOMContentLoaded', function() {
// Determine if this was a tap (short touch with minimal movement)
const absX = Math.abs(moveX || 0);
const absY = Math.abs(moveY || 0);
- const isTap = touchDuration < 300 && Math.max(absX, absY) < 10;
+ // More generous tap detection - increased movement threshold to 30px
+ const isTap = touchDuration < 300 && Math.max(absX, absY) < 30;
isDragging = false;
- if (isTap) {
- // This was a tap, not a swipe
+ if (isTap || !hasMoved) {
+ // This was a tap or minimal movement, not a swipe
resetCardPosition();
handleCardClick(e);
- } else if (Math.max(absX, absY) > swipeThreshold) {
+ } else if (Math.max(absX, absY) > swipeThreshold && touchDuration > 100) {
// This was a swipe
if (absX > absY) {
// Horizontal swipe
@@ -222,15 +389,30 @@ document.addEventListener('DOMContentLoaded', function() {
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);
@@ -242,9 +424,13 @@ document.addEventListener('DOMContentLoaded', function() {
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();
- }, 300);
+ }, 200); // Reduced from 300ms to 200ms for faster transitions
}
// Function to record a selection in the database
@@ -278,47 +464,152 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
+ // 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.5';
+ img.style.opacity = '0';
- // Fetch a random image from our API
- fetch('/random-image')
+ // 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');
+ throw new Error('Failed to fetch image: ' + response.status);
}
return response.json();
})
.then(data => {
- // Store current image info
- currentImageInfo = data;
+ console.log('Image data received:', data);
+ displayImage(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';
-
- // Update the image source
- img.onload = function() {
- img.style.opacity = '1';
- };
- img.src = data.path;
-
- // Update status with resolution info
- const statusElement = document.querySelector('.status-area p:first-child');
- statusElement.textContent = `Current resolution: ${data.resolution}`;
+ // 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 loading image:', error);
- img.style.opacity = '1';
- 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%3EImage%20not%20found%3C%2Ftext%3E%3C%2Fsvg%3E';
+ 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
diff --git a/styles.css b/styles.css
index 480a0da..2911481 100644
--- a/styles.css
+++ b/styles.css
@@ -5,20 +5,27 @@
font-family: 'Arial', sans-serif;
}
-body {
+html, body {
background-color: #f5f5f5;
color: #333;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
}
.container {
max-width: 500px;
margin: 0 auto;
padding: 20px;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
}
.header {
text-align: center;
- margin-bottom: 20px;
+ margin-bottom: 10px;
}
.subtitle {
@@ -29,8 +36,8 @@ body {
.history-link {
display: inline-block;
- margin-bottom: 20px;
- padding: 8px 15px;
+ margin-bottom: 10px;
+ padding: 5px 12px;
background-color: #333;
color: white;
text-decoration: none;
@@ -45,9 +52,17 @@ body {
.swipe-container {
position: relative;
- height: 400px;
+ flex: 1;
+ min-height: 400px;
margin-bottom: 20px;
perspective: 1000px;
+ border-style: solid;
+ border-width: 8px;
+ border-left-color: #ff4757; /* Red - Left (Discard) */
+ border-right-color: #2ed573; /* Green - Right (Keep) */
+ border-top-color: #1e90ff; /* Blue - Top (Favorite) */
+ border-bottom-color: #ffa502; /* Yellow - Bottom (Review Later) */
+ border-radius: 15px;
}
.image-card {
@@ -61,6 +76,43 @@ body {
transform-origin: center center;
background-color: white;
touch-action: none; /* Prevents default touch actions like scrolling */
+ cursor: grab; /* Indicates the card is draggable */
+}
+
+/* Animation for new card sliding in from left (desktop) */
+@keyframes slideInFromLeft {
+ 0% {
+ transform: translateX(-100%) rotate(-5deg);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateX(0) rotate(0deg);
+ opacity: 1;
+ }
+}
+
+.image-card.new-card {
+ animation: slideInFromLeft 0.25s ease-out forwards;
+ will-change: transform, opacity;
+}
+
+/* Simple fade-in animation for mobile */
+@keyframes fadeIn {
+ 0% {
+ opacity: 0.4;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+.image-card.new-card-mobile {
+ animation: fadeIn 0.2s ease-out forwards;
+ will-change: opacity;
+}
+
+.image-card:active {
+ cursor: grabbing; /* Changes cursor when actively dragging */
}
.image-card img {
@@ -90,23 +142,27 @@ body {
}
.image-card.swipe-left {
- transform: translateX(-150%) rotate(-20deg);
+ transform: translateX(-350%) rotate(-15deg);
opacity: 0;
+ transition: transform 0.5s ease-out, opacity 0.25s ease-out;
}
.image-card.swipe-right {
- transform: translateX(150%) rotate(20deg);
+ transform: translateX(350%) rotate(15deg);
opacity: 0;
+ transition: transform 0.5s ease-out, opacity 0.25s ease-out;
}
.image-card.swipe-up {
- transform: translateY(-150%) rotate(5deg);
+ transform: translateY(-350%) rotate(5deg);
opacity: 0;
+ transition: transform 0.5s ease-out, opacity 0.25s ease-out;
}
.image-card.swipe-down {
- transform: translateY(150%) rotate(-5deg);
+ transform: translateY(350%) rotate(-5deg);
opacity: 0;
+ transition: transform 0.5s ease-out, opacity 0.25s ease-out;
}
.action-hint {
@@ -149,18 +205,23 @@ body {
.action-buttons {
display: flex;
- justify-content: center;
- gap: 10px;
- margin-bottom: 20px;
+ justify-content: space-between;
+ margin: 15px 0 20px;
+ width: 100%;
}
.action-btn {
- padding: 10px 20px;
+ flex: 1;
+ padding: 12px 5px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
+ margin: 0 5px;
+ text-align: center;
+ font-size: 0.9rem;
+ white-space: nowrap;
}
#btn-left {
@@ -243,6 +304,62 @@ body {
background-color: #ffa502;
}
+/* Mobile-specific styles */
+@media (max-width: 768px) {
+ .container {
+ padding: 10px;
+ max-width: 100%;
+ }
+
+ /* Override desktop animation for mobile */
+ .image-card.new-card {
+ animation: none;
+ }
+
+ .swipe-container {
+ min-height: 60vh;
+ margin: 10px 0 15px;
+ }
+
+ .header {
+ margin-bottom: 10px;
+ }
+
+ /* Hide action buttons on mobile as swiping is the primary interaction */
+ .action-buttons {
+ display: none;
+ }
+
+ /* Hide the swipe legend on mobile to save space */
+ .swipe-legend {
+ display: none;
+ }
+
+ /* Make sure the history link is prominent but compact */
+ .history-link {
+ display: block;
+ text-align: center;
+ margin: 8px auto;
+ padding: 6px 15px;
+ font-size: 0.9rem;
+ }
+
+ /* Enhance swipe hints for better visibility on mobile */
+ .swipe-actions .action-hint {
+ font-size: 0.9rem;
+ padding: 5px;
+ background-color: rgba(0, 0, 0, 0.5);
+ color: white;
+ border-radius: 4px;
+ }
+
+ /* Adjust modal for mobile */
+ .modal-content {
+ width: 90%;
+ margin: 20% auto;
+ }
+}
+
/* Modal styles */
.modal {
display: none;