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
This commit is contained in:
281
app.py
281
app.py
@@ -7,6 +7,8 @@ import urllib.parse
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
|
||||||
# Path to the image directory
|
# Path to the image directory
|
||||||
IMAGE_DIR = "/mnt/secret-items/sd-outputs/Sorted/Images/Portrait"
|
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
|
# Database file path
|
||||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image_selections.db")
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image_selections.db")
|
||||||
|
|
||||||
# Initialize database
|
# Initialize the database
|
||||||
def init_db():
|
def init_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
cursor = conn.cursor()
|
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
|
# Create table if it doesn't exist
|
||||||
cursor.execute('''
|
if 'image_selections' not in [table[0] for table in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]:
|
||||||
CREATE TABLE IF NOT EXISTS image_selections (
|
cursor.execute('''
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
CREATE TABLE IF NOT EXISTS image_selections (
|
||||||
image_path TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
resolution TEXT NOT NULL,
|
image_path TEXT NOT NULL,
|
||||||
action TEXT NOT NULL,
|
resolution TEXT NOT NULL,
|
||||||
timestamp INTEGER 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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -36,14 +49,25 @@ def init_db():
|
|||||||
|
|
||||||
# Add a selection to the database
|
# Add a selection to the database
|
||||||
def add_selection(image_path, resolution, action):
|
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)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Insert the selection
|
# Insert the selection
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO image_selections (image_path, resolution, action, timestamp)
|
INSERT INTO image_selections (image_path, resolution, action, timestamp, orientation)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
''', (image_path, resolution, action, int(time.time())))
|
''', (image_path, resolution, action, int(time.time()), orientation))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -69,6 +93,23 @@ def get_selections():
|
|||||||
item = {}
|
item = {}
|
||||||
for key in row.keys():
|
for key in row.keys():
|
||||||
item[key] = row[key]
|
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)
|
results.append(item)
|
||||||
|
|
||||||
print(f"DEBUG: Converted {len(results)} rows to dictionaries")
|
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 empty list on error to prevent client from hanging
|
||||||
return []
|
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
|
# Update a selection in the database
|
||||||
def update_selection(selection_id, action):
|
def update_selection(selection_id, action):
|
||||||
conn = sqlite3.connect(DB_PATH)
|
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-Methods', 'GET, POST, OPTIONS')
|
||||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
# Parse the URL path
|
# Parse the URL
|
||||||
parsed_path = urllib.parse.urlparse(self.path)
|
parsed_url = urllib.parse.urlparse(self.path)
|
||||||
path = parsed_path.path
|
path = parsed_url.path
|
||||||
|
|
||||||
# Serve static files
|
# Handle different paths
|
||||||
if path == "/" or path == "":
|
if path == '/':
|
||||||
self.serve_file("index.html")
|
self.serve_file('index.html', 'text/html')
|
||||||
elif path == "/random-image":
|
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()
|
self.serve_random_image()
|
||||||
elif path == "/image-resolutions":
|
elif path == '/selections':
|
||||||
self.serve_resolutions()
|
|
||||||
elif path == "/selections":
|
|
||||||
self.serve_selections()
|
self.serve_selections()
|
||||||
elif path.startswith("/images/"):
|
elif path.startswith('/images/'):
|
||||||
# Extract the image path from the URL
|
self.serve_image(path[8:])
|
||||||
image_path = path[8:] # Remove "/images/" prefix
|
elif path.startswith('/download-selected'):
|
||||||
self.serve_image(image_path)
|
self.handle_download_selected()
|
||||||
else:
|
else:
|
||||||
# Serve other static files
|
# Try to serve as a static file
|
||||||
if path.startswith("/"):
|
if path.startswith('/'):
|
||||||
path = path[1:] # Remove leading slash
|
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:
|
try:
|
||||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), file_path), 'rb') as file:
|
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), file_path), 'rb') as file:
|
||||||
content = file.read()
|
content = file.read()
|
||||||
|
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
|
|
||||||
# Set the content type based on file extension
|
# Set the content type based on parameter or guess from file extension
|
||||||
content_type, _ = mimetypes.guess_type(file_path)
|
if not content_type:
|
||||||
|
content_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
|
||||||
if content_type:
|
if content_type:
|
||||||
self.send_header('Content-type', content_type)
|
self.send_header('Content-type', content_type)
|
||||||
else:
|
else:
|
||||||
@@ -214,23 +283,53 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_error(404, f"Image not found: {image_path}")
|
self.send_error(404, f"Image not found: {image_path}")
|
||||||
|
|
||||||
def serve_random_image(self):
|
def serve_random_image(self):
|
||||||
|
print("DEBUG: serve_random_image() called")
|
||||||
try:
|
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
|
# Get all resolution directories
|
||||||
resolutions = [d for d in os.listdir(IMAGE_DIR) if os.path.isdir(os.path.join(IMAGE_DIR, d))]
|
resolutions = [d for d in os.listdir(IMAGE_DIR) if os.path.isdir(os.path.join(IMAGE_DIR, d))]
|
||||||
|
|
||||||
# Choose a random resolution
|
# Try to find an unactioned image
|
||||||
resolution = random.choice(resolutions)
|
max_attempts = 20 # Limit the number of attempts to find an unactioned image
|
||||||
resolution_dir = os.path.join(IMAGE_DIR, resolution)
|
for attempt in range(max_attempts):
|
||||||
|
# Choose a random resolution
|
||||||
# Get all images in the selected resolution directory
|
resolution = random.choice(resolutions)
|
||||||
images = [f for f in os.listdir(resolution_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
|
resolution_dir = os.path.join(IMAGE_DIR, resolution)
|
||||||
|
|
||||||
if not images:
|
|
||||||
self.send_error(404, "No images found in the selected resolution directory")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Choose a random image
|
# Get all images in the selected resolution directory
|
||||||
image_name = random.choice(images)
|
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}"
|
image_path = f"{resolution}/{image_name}"
|
||||||
full_image_path = os.path.join(IMAGE_DIR, image_path)
|
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)}")
|
print(f"DEBUG ERROR getting file creation time: {str(e)}")
|
||||||
creation_date = "Unknown"
|
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
|
# Return the image path as JSON
|
||||||
response = {
|
response = {
|
||||||
'path': f"/images/{image_path}",
|
'path': f"/images/{image_path}",
|
||||||
'resolution': resolution,
|
'resolution': resolution,
|
||||||
'filename': image_name,
|
'filename': image_name,
|
||||||
'creation_date': creation_date
|
'creation_date': creation_date,
|
||||||
|
'orientation': orientation
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
@@ -322,8 +432,12 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
|
|||||||
self.handle_update_selection()
|
self.handle_update_selection()
|
||||||
elif path == "/delete-selection":
|
elif path == "/delete-selection":
|
||||||
self.handle_delete_selection()
|
self.handle_delete_selection()
|
||||||
elif path == "/reset-database":
|
elif self.path == '/reset-database':
|
||||||
self.handle_reset_database()
|
self.handle_reset_database()
|
||||||
|
return
|
||||||
|
elif self.path.startswith('/download-selected'):
|
||||||
|
self.handle_download_selected()
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
self.send_error(404, "Not found")
|
self.send_error(404, "Not found")
|
||||||
|
|
||||||
@@ -443,26 +557,67 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def handle_reset_database(self):
|
def handle_reset_database(self):
|
||||||
try:
|
try:
|
||||||
print("DEBUG: Handling reset database request")
|
reset_database()
|
||||||
# Reset the database
|
|
||||||
rows_affected = reset_database()
|
|
||||||
|
|
||||||
# Return success response
|
|
||||||
response = {
|
|
||||||
'success': True,
|
|
||||||
'message': f"Database reset: {rows_affected} selections deleted"
|
|
||||||
}
|
|
||||||
|
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header('Content-type', 'application/json')
|
self.send_header('Content-type', 'application/json')
|
||||||
self._set_cors_headers()
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps(response).encode())
|
self.wfile.write(json.dumps({'success': True, 'message': 'Database reset successfully'}).encode())
|
||||||
print("DEBUG: Reset database response sent")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"DEBUG ERROR in handle_reset_database(): {str(e)}")
|
print(f"DEBUG ERROR in handle_reset_database: {str(e)}")
|
||||||
self.send_error(500, f"Error resetting 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):
|
def do_OPTIONS(self):
|
||||||
# Handle preflight requests for CORS
|
# Handle preflight requests for CORS
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
|
|||||||
929
history.html
929
history.html
File diff suppressed because it is too large
Load Diff
12
index.html
12
index.html
@@ -9,8 +9,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Image Swiper</h1>
|
|
||||||
<p class="subtitle">Swipe to sort your images</p>
|
|
||||||
<a href="/history.html" class="history-link">View History</a>
|
<a href="/history.html" class="history-link">View History</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,10 +28,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button id="btn-left" class="action-btn">Left</button>
|
<button id="btn-left" class="action-btn">Discard</button>
|
||||||
<button id="btn-right" class="action-btn">Right</button>
|
<button id="btn-right" class="action-btn">Keep</button>
|
||||||
<button id="btn-up" class="action-btn">Up</button>
|
<button id="btn-up" class="action-btn">Favorite</button>
|
||||||
<button id="btn-down" class="action-btn">Down</button>
|
<button id="btn-down" class="action-btn">Review</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-area">
|
<div class="status-area">
|
||||||
@@ -43,7 +41,7 @@
|
|||||||
<div class="legend-item"><span class="legend-color left-color"></span> Left: Discard</div>
|
<div class="legend-item"><span class="legend-color left-color"></span> Left: Discard</div>
|
||||||
<div class="legend-item"><span class="legend-color right-color"></span> Right: Keep</div>
|
<div class="legend-item"><span class="legend-color right-color"></span> Right: Keep</div>
|
||||||
<div class="legend-item"><span class="legend-color up-color"></span> Up: Favorite</div>
|
<div class="legend-item"><span class="legend-color up-color"></span> Up: Favorite</div>
|
||||||
<div class="legend-item"><span class="legend-color down-color"></span> Down: Review Later</div>
|
<div class="legend-item"><span class="legend-color down-color"></span> Down: Review</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
375
script.js
375
script.js
@@ -5,6 +5,105 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const rightHint = document.querySelector('.right-hint');
|
const rightHint = document.querySelector('.right-hint');
|
||||||
const upHint = document.querySelector('.up-hint');
|
const upHint = document.querySelector('.up-hint');
|
||||||
const downHint = document.querySelector('.down-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
|
// Modal elements
|
||||||
const modal = document.getElementById('fullscreen-modal');
|
const modal = document.getElementById('fullscreen-modal');
|
||||||
@@ -29,12 +128,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Touch variables
|
// Touch variables
|
||||||
let startX, startY, moveX, moveY;
|
let startX, startY, moveX, moveY;
|
||||||
let isDragging = false;
|
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
|
// Touch event handlers with passive: false for better mobile performance
|
||||||
card.addEventListener('touchstart', handleTouchStart, false);
|
card.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
card.addEventListener('touchmove', handleTouchMove, false);
|
card.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
card.addEventListener('touchend', handleTouchEnd, false);
|
card.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||||
|
|
||||||
// Mouse event handlers (for desktop testing)
|
// Mouse event handlers (for desktop testing)
|
||||||
card.addEventListener('mousedown', handleMouseDown, false);
|
card.addEventListener('mousedown', handleMouseDown, false);
|
||||||
@@ -64,26 +164,92 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleTouchStart(e) {
|
function handleTouchStart(e) {
|
||||||
startX = e.touches[0].clientX;
|
// Store the initial position and set the dragging flag
|
||||||
startY = e.touches[0].clientY;
|
const touch = e.touches[0];
|
||||||
|
startX = touch.clientX;
|
||||||
|
startY = touch.clientY;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
hasMoved = false; // Reset movement tracking
|
||||||
card.classList.add('swiping');
|
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();
|
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) {
|
function handleTouchMove(e) {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
moveX = e.touches[0].clientX - startX;
|
const touch = e.touches[0];
|
||||||
moveY = e.touches[0].clientY - startY;
|
moveX = touch.clientX - startX;
|
||||||
|
moveY = touch.clientY - startY;
|
||||||
|
|
||||||
// Apply transform to the card
|
// Check if we've moved significantly
|
||||||
card.style.transform = `translate(${moveX}px, ${moveY}px) rotate(${moveX * 0.1}deg)`;
|
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);
|
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) {
|
function handleTouchEnd(e) {
|
||||||
@@ -96,15 +262,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Determine if this was a tap (short touch with minimal movement)
|
// Determine if this was a tap (short touch with minimal movement)
|
||||||
const absX = Math.abs(moveX || 0);
|
const absX = Math.abs(moveX || 0);
|
||||||
const absY = Math.abs(moveY || 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;
|
isDragging = false;
|
||||||
|
|
||||||
if (isTap) {
|
if (isTap || !hasMoved) {
|
||||||
// This was a tap, not a swipe
|
// This was a tap or minimal movement, not a swipe
|
||||||
resetCardPosition();
|
resetCardPosition();
|
||||||
handleCardClick(e);
|
handleCardClick(e);
|
||||||
} else if (Math.max(absX, absY) > swipeThreshold) {
|
} else if (Math.max(absX, absY) > swipeThreshold && touchDuration > 100) {
|
||||||
// This was a swipe
|
// This was a swipe
|
||||||
if (absX > absY) {
|
if (absX > absY) {
|
||||||
// Horizontal swipe
|
// Horizontal swipe
|
||||||
@@ -222,15 +389,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
function resetCardPosition() {
|
function resetCardPosition() {
|
||||||
card.classList.remove('swiping');
|
card.classList.remove('swiping');
|
||||||
card.style.transform = '';
|
card.style.transform = '';
|
||||||
|
card.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function performSwipe(direction) {
|
function performSwipe(direction) {
|
||||||
// Add the appropriate swipe class
|
// Add the appropriate swipe class
|
||||||
card.classList.add(`swipe-${direction}`);
|
card.classList.add(`swipe-${direction}`);
|
||||||
|
|
||||||
|
// Start fading out immediately
|
||||||
|
card.style.opacity = '0.8';
|
||||||
|
|
||||||
// Update the last action text
|
// Update the last action text
|
||||||
lastActionText.textContent = `Last action: Swiped ${direction}`;
|
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
|
// Record the selection in the database if we have a current image
|
||||||
if (currentImageInfo) {
|
if (currentImageInfo) {
|
||||||
recordSelection(currentImageInfo, direction);
|
recordSelection(currentImageInfo, direction);
|
||||||
@@ -242,9 +424,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
card.classList.remove('swiping');
|
card.classList.remove('swiping');
|
||||||
card.style.transform = '';
|
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
|
// Load a new random image from our server
|
||||||
loadNewImage();
|
loadNewImage();
|
||||||
}, 300);
|
}, 200); // Reduced from 300ms to 200ms for faster transitions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to record a selection in the database
|
// 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 to load a new image from our local server
|
||||||
function loadNewImage() {
|
function loadNewImage() {
|
||||||
|
console.log('loadNewImage called');
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const img = card.querySelector('img');
|
const img = card.querySelector('img');
|
||||||
img.style.opacity = '0.5';
|
img.style.opacity = '0';
|
||||||
|
|
||||||
// Fetch a random image from our API
|
// Remove all animation classes to reset
|
||||||
fetch('/random-image')
|
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 => {
|
.then(response => {
|
||||||
|
console.log('Fetch response received:', response.status);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch image');
|
throw new Error('Failed to fetch image: ' + response.status);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Store current image info
|
console.log('Image data received:', data);
|
||||||
currentImageInfo = data;
|
displayImage(data);
|
||||||
|
|
||||||
// Extract filename from path
|
// After displaying the first image, prefetch more for the cache
|
||||||
const pathParts = data.path.split('/');
|
if (imageCache.images.length < imageCache.maxSize) {
|
||||||
const filename = pathParts[pathParts.length - 1];
|
prefetchImages(imageCache.maxSize - imageCache.images.length);
|
||||||
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}`;
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error loading image:', error);
|
console.error('Error fetching random image:', error);
|
||||||
img.style.opacity = '1';
|
// Handle the error (e.g., display an error message)
|
||||||
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';
|
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 to handle card click for viewing full-resolution image
|
||||||
function handleCardClick(e) {
|
function handleCardClick(e) {
|
||||||
// Only process click if we have image info and we're not in the middle of a swipe
|
// Only process click if we have image info and we're not in the middle of a swipe
|
||||||
|
|||||||
143
styles.css
143
styles.css
@@ -5,20 +5,27 @@
|
|||||||
font-family: 'Arial', sans-serif;
|
font-family: 'Arial', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
@@ -29,8 +36,8 @@ body {
|
|||||||
|
|
||||||
.history-link {
|
.history-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
padding: 8px 15px;
|
padding: 5px 12px;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -45,9 +52,17 @@ body {
|
|||||||
|
|
||||||
.swipe-container {
|
.swipe-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 400px;
|
flex: 1;
|
||||||
|
min-height: 400px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
perspective: 1000px;
|
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 {
|
.image-card {
|
||||||
@@ -61,6 +76,43 @@ body {
|
|||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
touch-action: none; /* Prevents default touch actions like scrolling */
|
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 {
|
.image-card img {
|
||||||
@@ -90,23 +142,27 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.image-card.swipe-left {
|
.image-card.swipe-left {
|
||||||
transform: translateX(-150%) rotate(-20deg);
|
transform: translateX(-350%) rotate(-15deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card.swipe-right {
|
.image-card.swipe-right {
|
||||||
transform: translateX(150%) rotate(20deg);
|
transform: translateX(350%) rotate(15deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card.swipe-up {
|
.image-card.swipe-up {
|
||||||
transform: translateY(-150%) rotate(5deg);
|
transform: translateY(-350%) rotate(5deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card.swipe-down {
|
.image-card.swipe-down {
|
||||||
transform: translateY(150%) rotate(-5deg);
|
transform: translateY(350%) rotate(-5deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-hint {
|
.action-hint {
|
||||||
@@ -149,18 +205,23 @@ body {
|
|||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
margin: 15px 0 20px;
|
||||||
margin-bottom: 20px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
padding: 10px 20px;
|
flex: 1;
|
||||||
|
padding: 12px 5px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
margin: 0 5px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#btn-left {
|
#btn-left {
|
||||||
@@ -243,6 +304,62 @@ body {
|
|||||||
background-color: #ffa502;
|
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 styles */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user