From c09461f58ff9a8b0ba2b8c8ce81818ee7bc4322b Mon Sep 17 00:00:00 2001 From: Aodhan Date: Thu, 29 May 2025 01:31:26 +0100 Subject: [PATCH] Enhance image swipe app mobile experience 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 --- app.py | 281 ++++++++++++---- history.html | 929 ++++++++++++++++++++++++++++++++------------------- index.html | 12 +- script.js | 375 ++++++++++++++++++--- styles.css | 143 +++++++- 5 files changed, 1275 insertions(+), 465 deletions(-) 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; } -