Basic UI
This commit is contained in:
282
app.py
282
app.py
@@ -21,53 +21,42 @@ 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
|
||||
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")
|
||||
# Create image_selections table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS image_selections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
image_path TEXT NOT NULL UNIQUE,
|
||||
action TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# Create image_metadata table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS image_metadata (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
resolution TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
orientation TEXT NOT NULL,
|
||||
discovered_at INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Database initialized at {DB_PATH}")
|
||||
|
||||
# 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)}")
|
||||
|
||||
def add_selection(image_path, action):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Insert the selection
|
||||
# Use REPLACE INTO to handle potential duplicates gracefully
|
||||
cursor.execute('''
|
||||
INSERT INTO image_selections (image_path, resolution, action, timestamp, orientation)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (image_path, resolution, action, int(time.time()), orientation))
|
||||
REPLACE INTO image_selections (image_path, action, timestamp)
|
||||
VALUES (?, ?, ?)
|
||||
''', (image_path, action, int(time.time())))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -98,7 +87,6 @@ def get_selections():
|
||||
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:]
|
||||
@@ -123,23 +111,65 @@ def get_selections():
|
||||
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]
|
||||
|
||||
def sync_image_database():
|
||||
"""Scans the image directory and adds any new images to the metadata table."""
|
||||
print("Syncing image database...")
|
||||
from PIL import Image
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all image paths already in the database
|
||||
cursor.execute("SELECT path FROM image_metadata")
|
||||
db_images = {row[0] for row in cursor.fetchall()}
|
||||
print(f"Found {len(db_images)} images in the database.")
|
||||
|
||||
# Find all images on the filesystem
|
||||
disk_images = set()
|
||||
resolutions = [d for d in os.listdir(IMAGE_DIR) if os.path.isdir(os.path.join(IMAGE_DIR, d))]
|
||||
for res in resolutions:
|
||||
res_dir = os.path.join(IMAGE_DIR, res)
|
||||
for img_name in os.listdir(res_dir):
|
||||
if img_name.lower().endswith(('.png', '.jpg', '.jpeg')):
|
||||
disk_images.add(f"{res}/{img_name}")
|
||||
print(f"Found {len(disk_images)} images on disk.")
|
||||
|
||||
# Determine which images are new
|
||||
new_images = disk_images - db_images
|
||||
print(f"Found {len(new_images)} new images to add to the database.")
|
||||
|
||||
if not new_images:
|
||||
print("Database is already up-to-date.")
|
||||
conn.close()
|
||||
return actioned_images
|
||||
except Exception as e:
|
||||
print(f"DEBUG ERROR in get_actioned_images(): {str(e)}")
|
||||
return []
|
||||
return
|
||||
|
||||
# Process and add new images to the database
|
||||
images_to_add = []
|
||||
total_new_images = len(new_images)
|
||||
processed_count = 0
|
||||
for image_path in new_images:
|
||||
res, img_name = image_path.split('/', 1)
|
||||
full_path = os.path.join(IMAGE_DIR, image_path)
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
width, height = img.size
|
||||
orientation = 'landscape' if width >= height else 'portrait'
|
||||
images_to_add.append((image_path, res, img_name, orientation, int(time.time())))
|
||||
processed_count += 1
|
||||
if processed_count % 100 == 0 or processed_count == total_new_images:
|
||||
percentage = (processed_count / total_new_images) * 100
|
||||
print(f"Processed {processed_count} of {total_new_images} images ({percentage:.2f}%)...", flush=True)
|
||||
except Exception as e:
|
||||
print(f"Could not process image {full_path}: {e}")
|
||||
|
||||
if images_to_add:
|
||||
cursor.executemany('''
|
||||
INSERT INTO image_metadata (path, resolution, name, orientation, discovered_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', images_to_add)
|
||||
conn.commit()
|
||||
print(f"Successfully added {len(images_to_add)} new images to the database.")
|
||||
|
||||
conn.close()
|
||||
|
||||
# Update a selection in the database
|
||||
def update_selection(selection_id, action):
|
||||
@@ -197,6 +227,8 @@ def reset_database():
|
||||
print(f"DEBUG: Reset database - deleted {rows_affected} selections")
|
||||
return rows_affected
|
||||
|
||||
|
||||
|
||||
class ImageSwipeHandler(BaseHTTPRequestHandler):
|
||||
# Set response headers for CORS
|
||||
def _set_cors_headers(self):
|
||||
@@ -233,6 +265,34 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
|
||||
self.serve_file(path)
|
||||
except:
|
||||
self.send_error(404, "File not found")
|
||||
|
||||
def do_POST(self):
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
|
||||
if path == '/selection':
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length)
|
||||
data = json.loads(post_data)
|
||||
|
||||
add_selection(data['image_path'], data['action'])
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self._set_cors_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'success'}).encode())
|
||||
except Exception as e:
|
||||
print(f"ERROR in do_POST /selection: {e}")
|
||||
self.send_error(500, f"Server error processing selection: {e}")
|
||||
else:
|
||||
self.send_error(404, "Endpoint not found")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._set_cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
def serve_file(self, file_path, content_type=None):
|
||||
try:
|
||||
@@ -283,75 +343,61 @@ 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))]
|
||||
|
||||
# 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)
|
||||
|
||||
# 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}"
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
query_params = urllib.parse.parse_qs(parsed_url.query)
|
||||
orientation_filter = query_params.get('orientation', ['all'])[0]
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Base query to get unactioned images
|
||||
query = """
|
||||
SELECT meta.path, meta.resolution, meta.name, meta.orientation
|
||||
FROM image_metadata meta
|
||||
LEFT JOIN image_selections sel ON meta.path = sel.image_path
|
||||
WHERE sel.image_path IS NULL
|
||||
"""
|
||||
|
||||
# Add orientation filter if specified
|
||||
params = ()
|
||||
if orientation_filter != 'all':
|
||||
query += " AND meta.orientation = ?"
|
||||
params = (orientation_filter,)
|
||||
|
||||
cursor.execute(query, params)
|
||||
possible_images = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not possible_images:
|
||||
print("DEBUG: No matching unactioned images found.")
|
||||
response = {'message': 'No more images available for this filter.'}
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self._set_cors_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response).encode())
|
||||
return
|
||||
|
||||
# Choose one random image from the filtered list
|
||||
chosen_image_row = random.choice(possible_images)
|
||||
image_path = chosen_image_row[0]
|
||||
resolution = chosen_image_row[1]
|
||||
image_name = chosen_image_row[2]
|
||||
orientation = chosen_image_row[3]
|
||||
full_image_path = os.path.join(IMAGE_DIR, image_path)
|
||||
|
||||
# Get the file creation time
|
||||
print(f"DEBUG: Serving image: {image_path}")
|
||||
|
||||
# Get file metadata
|
||||
try:
|
||||
file_stat = os.stat(full_image_path)
|
||||
creation_time = file_stat.st_mtime # Use modification time as creation time
|
||||
creation_time = file_stat.st_mtime
|
||||
creation_date = datetime.datetime.fromtimestamp(creation_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception as e:
|
||||
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}",
|
||||
@@ -367,6 +413,7 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response).encode())
|
||||
except Exception as e:
|
||||
print(f"FATAL ERROR in serve_random_image: {e}")
|
||||
self.send_error(500, f"Error serving random image: {str(e)}")
|
||||
|
||||
def serve_resolutions(self):
|
||||
@@ -627,7 +674,14 @@ class ImageSwipeHandler(BaseHTTPRequestHandler):
|
||||
def run(server_class=HTTPServer, handler_class=ImageSwipeHandler, port=8000):
|
||||
# Initialize the database
|
||||
init_db()
|
||||
|
||||
|
||||
# Ensure the 'images' directory exists
|
||||
if not os.path.exists(IMAGE_DIR):
|
||||
os.makedirs(IMAGE_DIR)
|
||||
|
||||
# Sync the image database on startup
|
||||
sync_image_database()
|
||||
|
||||
server_address = ('', port)
|
||||
httpd = server_class(server_address, handler_class)
|
||||
print(f"Starting server on port {port}...")
|
||||
|
||||
1
components/button.jsx
Normal file
1
components/button.jsx
Normal file
@@ -0,0 +1 @@
|
||||
// This file will contain the button component.
|
||||
857
history.html
857
history.html
@@ -5,325 +5,43 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Selection History</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.history-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 8px 15px;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
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));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.selection-item {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.selection-action {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.selection-item:hover .selection-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #1e90ff;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.action-left {
|
||||
background-color: #ff4757;
|
||||
}
|
||||
|
||||
.action-right {
|
||||
background-color: #2ed573;
|
||||
}
|
||||
|
||||
.action-up {
|
||||
background-color: #1e90ff;
|
||||
}
|
||||
|
||||
.action-down {
|
||||
background-color: #ffa502;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.no-selections {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #666;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha512-KfkFx7UiO/8VdM4DJ8GIzQ3pObu7q9gP/yu1ZPTM0u88Z+cIXtA8nKg9ePC60zY+XvKw5xpbIX8zahPszp5C8w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Action change modal -->
|
||||
<div id="action-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 400px; height: auto;">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal" id="close-action-modal">×</span>
|
||||
<h2>Change Action</h2>
|
||||
<div id="modal-image-preview" style="margin: 15px 0; text-align: center;">
|
||||
<img id="modal-preview-img" src="" alt="Image preview" style="max-height: 200px; max-width: 100%;">
|
||||
<div id="modal-image-preview">
|
||||
<img id="modal-preview-img" src="" alt="Image preview">
|
||||
</div>
|
||||
<div class="action-buttons" style="margin: 20px 0;">
|
||||
<button class="action-btn" data-action="left" style="background-color: #ff4757;">Discard</button>
|
||||
<button class="action-btn" data-action="right" style="background-color: #2ed573;">Keep</button>
|
||||
<button class="action-btn" data-action="up" style="background-color: #1e90ff;">Favorite</button>
|
||||
<button class="action-btn" data-action="down" style="background-color: #ffa502;">Review</button>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" data-action="left">Discard</button>
|
||||
<button class="action-btn" data-action="right">Keep</button>
|
||||
<button class="action-btn" data-action="up">Favorite</button>
|
||||
<button class="action-btn" data-action="down">Review</button>
|
||||
</div>
|
||||
<div id="modal-message" style="text-align: center; margin-top: 10px; color: #666;"></div>
|
||||
<div id="modal-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-container">
|
||||
<div class="history-header">
|
||||
<h1>Image Selection History</h1>
|
||||
<div class="header-buttons">
|
||||
<a href="/" class="back-button">Back to Swipe</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset confirmation modal -->
|
||||
<div id="reset-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 400px; height: auto;">
|
||||
<h2>Reset Database</h2>
|
||||
<p>Are you sure you want to delete all selections? This cannot be undone.</p>
|
||||
<div class="reset-modal-buttons">
|
||||
<button id="confirm-reset" class="danger-button">Yes, Delete All</button>
|
||||
<button id="cancel-reset" class="cancel-button">Cancel</button>
|
||||
</div>
|
||||
<div id="reset-message" style="text-align: center; margin-top: 15px; color: #666;"></div>
|
||||
<div id="reset-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Reset Database</h2>
|
||||
<p>Are you sure you want to delete all selections? This cannot be undone.</p>
|
||||
<div class="reset-modal-buttons">
|
||||
<button id="confirm-reset" class="danger-button">Yes, Delete All</button>
|
||||
<button id="cancel-reset" class="cancel-button">Cancel</button>
|
||||
</div>
|
||||
<div id="reset-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<h1 class="app-title"><i class="fa-solid fa-images"></i> History</h1>
|
||||
<a href="/" class="history-link">Back to Swipe</a>
|
||||
</header>
|
||||
|
||||
<div class="filter-container">
|
||||
<div class="filter-section">
|
||||
@@ -333,10 +51,9 @@
|
||||
<button class="filter-btn" data-filter="left">Discarded</button>
|
||||
<button class="filter-btn" data-filter="right">Kept</button>
|
||||
<button class="filter-btn" data-filter="up">Favorited</button>
|
||||
<button class="filter-btn" data-filter="down">Review Later</button>
|
||||
<button class="filter-btn" data-filter="down">Review</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>Orientation</h4>
|
||||
<div class="filter-buttons orientation-filters">
|
||||
@@ -346,532 +63,26 @@
|
||||
<button class="filter-btn" data-orientation="square">Square</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>Resolution</h4>
|
||||
<select id="resolution-filter" class="resolution-select">
|
||||
<option value="all">All Resolutions</option>
|
||||
<!-- This will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button id="reset-db" class="reset-btn">Reset Database</button>
|
||||
<button id="select-all" class="select-btn">Select All</button>
|
||||
<button id="deselect-all" class="select-btn">Deselect All</button>
|
||||
<button id="download-selected" class="download-btn" disabled>Download Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button id="reset-db" class="action-btn reset-btn"><i class="fa-solid fa-trash"></i><span class="label">Reset</span></button>
|
||||
<button id="select-all" class="action-btn select-btn"><i class="fa-solid fa-check-double"></i><span class="label">Select All</span></button>
|
||||
<button id="deselect-all" class="action-btn select-btn"><i class="fa-regular fa-square"></i><span class="label">Deselect All</span></button>
|
||||
<button id="download-selected" class="action-btn download-btn" disabled><i class="fa-solid fa-download"></i><span class="label">Download</span></button>
|
||||
</div>
|
||||
|
||||
<div id="selection-grid" class="selection-grid">
|
||||
<!-- Selection items will be loaded here dynamically -->
|
||||
<div class="no-selections">Loading selections...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM elements
|
||||
const selectionGrid = document.getElementById('selection-grid');
|
||||
const filterButtons = document.querySelectorAll('.filter-buttons .filter-btn');
|
||||
const orientationButtons = document.querySelectorAll('.orientation-filters .filter-btn');
|
||||
const resolutionFilter = document.getElementById('resolution-filter');
|
||||
const selectAllBtn = document.getElementById('select-all');
|
||||
const deselectAllBtn = document.getElementById('deselect-all');
|
||||
const downloadSelectedBtn = document.getElementById('download-selected');
|
||||
|
||||
// Action modal elements
|
||||
const actionModal = document.getElementById('action-modal');
|
||||
const closeActionModal = document.getElementById('close-action-modal');
|
||||
const actionButtons = document.querySelectorAll('.action-btn');
|
||||
const modalPreviewImg = document.getElementById('modal-preview-img');
|
||||
const modalMessage = document.getElementById('modal-message');
|
||||
|
||||
// Reset modal elements
|
||||
const resetBtn = document.getElementById('reset-db');
|
||||
const resetModal = document.getElementById('reset-modal');
|
||||
const confirmResetBtn = document.getElementById('confirm-reset');
|
||||
const cancelResetBtn = document.getElementById('cancel-reset');
|
||||
const resetMessage = document.getElementById('reset-message');
|
||||
|
||||
// State variables
|
||||
let currentFilter = 'all';
|
||||
let currentOrientation = 'all';
|
||||
let currentResolution = 'all';
|
||||
let selectedItems = [];
|
||||
let currentSelectionId = null;
|
||||
|
||||
// Load selections on page load
|
||||
loadSelections();
|
||||
|
||||
// Action filter button click handlers
|
||||
filterButtons.forEach(button => {
|
||||
if (button.dataset.filter) {
|
||||
button.addEventListener('click', function() {
|
||||
// Remove active class from all action filter buttons
|
||||
filterButtons.forEach(btn => {
|
||||
if (btn.dataset.filter) btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to clicked button
|
||||
this.classList.add('active');
|
||||
|
||||
// Update current filter
|
||||
currentFilter = this.dataset.filter;
|
||||
|
||||
// Reload selections with new filter
|
||||
loadSelections();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Orientation filter button click handlers
|
||||
orientationButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
// Remove active class from all orientation filter buttons
|
||||
orientationButtons.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button
|
||||
this.classList.add('active');
|
||||
|
||||
// Update current orientation filter
|
||||
currentOrientation = this.dataset.orientation;
|
||||
|
||||
// Reload selections with new filter
|
||||
loadSelections();
|
||||
});
|
||||
});
|
||||
|
||||
// Resolution filter change handler
|
||||
resolutionFilter.addEventListener('change', function() {
|
||||
currentResolution = this.value;
|
||||
loadSelections();
|
||||
});
|
||||
|
||||
// Select All button handler
|
||||
selectAllBtn.addEventListener('click', function() {
|
||||
const checkboxes = document.querySelectorAll('.selection-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = true;
|
||||
const selectionItem = checkbox.closest('.selection-item');
|
||||
selectionItem.classList.add('selected');
|
||||
|
||||
// Add to selected items if not already there
|
||||
const id = selectionItem.dataset.id;
|
||||
if (!selectedItems.some(item => item.id === id)) {
|
||||
// Get the image path from the img element
|
||||
let imagePath = selectionItem.querySelector('img').src;
|
||||
// Convert absolute URL to relative path
|
||||
if (imagePath.includes('localhost')) {
|
||||
// Extract just the path portion from the full URL
|
||||
const url = new URL(imagePath);
|
||||
imagePath = url.pathname;
|
||||
}
|
||||
|
||||
const selection = {
|
||||
id: id,
|
||||
image_path: imagePath,
|
||||
action: selectionItem.dataset.action,
|
||||
resolution: selectionItem.dataset.resolution,
|
||||
orientation: selectionItem.dataset.orientation
|
||||
};
|
||||
selectedItems.push(selection);
|
||||
}
|
||||
});
|
||||
updateDownloadButton();
|
||||
});
|
||||
|
||||
// Deselect All button handler
|
||||
deselectAllBtn.addEventListener('click', function() {
|
||||
const checkboxes = document.querySelectorAll('.selection-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
checkbox.closest('.selection-item').classList.remove('selected');
|
||||
});
|
||||
selectedItems = [];
|
||||
updateDownloadButton();
|
||||
});
|
||||
|
||||
// Download Selected button handler
|
||||
downloadSelectedBtn.addEventListener('click', function() {
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
// Create URL with image paths
|
||||
const paths = selectedItems.map(item => {
|
||||
let path = item.image_path;
|
||||
// Ensure path starts with /images/
|
||||
if (!path.startsWith('/images/')) {
|
||||
path = `/images/${path}`;
|
||||
}
|
||||
return path;
|
||||
});
|
||||
const queryString = paths.map(path => `paths=${encodeURIComponent(path)}`).join('&');
|
||||
const downloadUrl = `/download-selected?${queryString}`;
|
||||
|
||||
// Create a temporary link and click it to trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = 'selected_images.zip';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
|
||||
// Close action modal handler
|
||||
closeActionModal.addEventListener('click', function() {
|
||||
actionModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Action button click handlers
|
||||
actionButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const action = this.dataset.action;
|
||||
updateSelectionAction(currentSelectionId, action);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset button click handler
|
||||
resetBtn.addEventListener('click', function() {
|
||||
resetModal.style.display = 'block';
|
||||
resetMessage.textContent = '';
|
||||
});
|
||||
|
||||
// Confirm reset button handler
|
||||
confirmResetBtn.addEventListener('click', function() {
|
||||
resetDatabase();
|
||||
});
|
||||
|
||||
// Cancel reset button handler
|
||||
cancelResetBtn.addEventListener('click', function() {
|
||||
resetModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Close modals when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target === actionModal) {
|
||||
actionModal.style.display = 'none';
|
||||
}
|
||||
if (event.target === resetModal) {
|
||||
resetModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Function to load selections from the server
|
||||
function loadSelections() {
|
||||
console.log('DEBUG: loadSelections() called');
|
||||
selectionGrid.innerHTML = `<div class="no-selections">Loading selections...</div>`;
|
||||
|
||||
fetch('/selections')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Selections data:', data);
|
||||
if (data.selections && data.selections.length > 0) {
|
||||
// Populate resolution filter
|
||||
populateResolutionFilter(data.selections);
|
||||
// Render the selections
|
||||
renderSelections(data.selections);
|
||||
} else {
|
||||
selectionGrid.innerHTML = '<div class="no-selections">No selections found</div>';
|
||||
downloadSelectedBtn.disabled = true;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading selections:', error);
|
||||
selectionGrid.innerHTML = `<div class="error">Error loading selections: ${error.message}</div>`;
|
||||
downloadSelectedBtn.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to populate resolution filter
|
||||
function populateResolutionFilter(selections) {
|
||||
// Get unique resolutions
|
||||
const resolutions = [...new Set(selections.map(s => s.resolution))];
|
||||
resolutions.sort();
|
||||
|
||||
// Clear existing options except 'All'
|
||||
while (resolutionFilter.options.length > 1) {
|
||||
resolutionFilter.remove(1);
|
||||
}
|
||||
|
||||
// Add resolution options
|
||||
resolutions.forEach(resolution => {
|
||||
const option = document.createElement('option');
|
||||
option.value = resolution;
|
||||
option.textContent = resolution;
|
||||
resolutionFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to render selections
|
||||
function renderSelections(selections) {
|
||||
// Clear the grid
|
||||
selectionGrid.innerHTML = '';
|
||||
|
||||
// Apply all filters
|
||||
let filteredSelections = selections;
|
||||
|
||||
// Filter by action
|
||||
if (currentFilter !== 'all') {
|
||||
filteredSelections = filteredSelections.filter(s => s.action === currentFilter);
|
||||
}
|
||||
|
||||
// Filter by orientation
|
||||
if (currentOrientation !== 'all') {
|
||||
filteredSelections = filteredSelections.filter(s => s.orientation === currentOrientation);
|
||||
}
|
||||
|
||||
// Filter by resolution
|
||||
if (currentResolution !== 'all') {
|
||||
filteredSelections = filteredSelections.filter(s => s.resolution === currentResolution);
|
||||
}
|
||||
|
||||
if (filteredSelections.length === 0) {
|
||||
selectionGrid.innerHTML = '<div class="no-selections">No selections match the current filters</div>';
|
||||
downloadSelectedBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset selected items
|
||||
selectedItems = [];
|
||||
updateDownloadButton();
|
||||
|
||||
// Create and append selection items
|
||||
filteredSelections.forEach(selection => {
|
||||
const selectionItem = document.createElement('div');
|
||||
selectionItem.className = 'selection-item';
|
||||
selectionItem.dataset.id = selection.id;
|
||||
selectionItem.dataset.action = selection.action;
|
||||
selectionItem.dataset.orientation = selection.orientation || 'unknown';
|
||||
selectionItem.dataset.resolution = selection.resolution;
|
||||
|
||||
// Create checkbox container
|
||||
const checkboxContainer = document.createElement('div');
|
||||
checkboxContainer.className = 'selection-checkbox-container';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'selection-checkbox';
|
||||
checkbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
selectionItem.classList.add('selected');
|
||||
selectedItems.push(selection);
|
||||
} else {
|
||||
selectionItem.classList.remove('selected');
|
||||
selectedItems = selectedItems.filter(item => item.id !== selection.id);
|
||||
}
|
||||
updateDownloadButton();
|
||||
});
|
||||
checkboxContainer.appendChild(checkbox);
|
||||
|
||||
// Create image container
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'selection-image-container';
|
||||
|
||||
// Create image
|
||||
const img = document.createElement('img');
|
||||
// Ensure image path starts with /images/
|
||||
let imagePath = selection.image_path;
|
||||
if (!imagePath.startsWith('/images/')) {
|
||||
imagePath = `/images/${imagePath}`;
|
||||
}
|
||||
img.src = imagePath;
|
||||
img.alt = 'Selected image';
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
// Create action badge
|
||||
const actionBadge = document.createElement('div');
|
||||
actionBadge.className = `selection-action action-${selection.action}`;
|
||||
actionBadge.textContent = getActionName(selection.action);
|
||||
|
||||
// Create info container
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'selection-info';
|
||||
|
||||
// Add path, resolution, orientation and timestamp
|
||||
const pathText = document.createElement('p');
|
||||
pathText.textContent = selection.image_path.split('/').pop(); // Just show filename
|
||||
|
||||
const resolutionText = document.createElement('p');
|
||||
resolutionText.textContent = `Resolution: ${selection.resolution}`;
|
||||
|
||||
const orientationText = document.createElement('p');
|
||||
orientationText.textContent = `Orientation: ${selection.orientation || 'Unknown'}`;
|
||||
|
||||
const timestampText = document.createElement('p');
|
||||
const date = new Date(selection.timestamp * 1000);
|
||||
timestampText.textContent = `Date: ${date.toLocaleString()}`;
|
||||
|
||||
infoContainer.appendChild(pathText);
|
||||
infoContainer.appendChild(resolutionText);
|
||||
infoContainer.appendChild(orientationText);
|
||||
infoContainer.appendChild(timestampText);
|
||||
|
||||
// Create controls container
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.className = 'selection-controls';
|
||||
|
||||
// Create edit button
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'control-btn edit-btn';
|
||||
editBtn.textContent = 'Change';
|
||||
editBtn.addEventListener('click', function() {
|
||||
openActionModal(selection);
|
||||
});
|
||||
|
||||
// Create delete button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'control-btn delete-btn';
|
||||
deleteBtn.textContent = 'Remove';
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
deleteSelection(selection.id);
|
||||
});
|
||||
|
||||
controlsContainer.appendChild(editBtn);
|
||||
controlsContainer.appendChild(deleteBtn);
|
||||
|
||||
// Assemble the selection item
|
||||
selectionItem.appendChild(checkboxContainer);
|
||||
selectionItem.appendChild(imgContainer);
|
||||
selectionItem.appendChild(actionBadge);
|
||||
selectionItem.appendChild(infoContainer);
|
||||
selectionItem.appendChild(controlsContainer);
|
||||
|
||||
selectionGrid.appendChild(selectionItem);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to update download button state
|
||||
function updateDownloadButton() {
|
||||
if (selectedItems.length > 0) {
|
||||
downloadSelectedBtn.disabled = false;
|
||||
downloadSelectedBtn.textContent = `Download Selected (${selectedItems.length})`;
|
||||
} else {
|
||||
downloadSelectedBtn.disabled = true;
|
||||
downloadSelectedBtn.textContent = 'Download Selected';
|
||||
}
|
||||
}
|
||||
|
||||
// Function to open the action change modal
|
||||
function openActionModal(selection) {
|
||||
console.log('DEBUG: Opening action modal for selection:', selection);
|
||||
currentSelectionId = selection.id;
|
||||
modalPreviewImg.src = selection.image_path;
|
||||
modalMessage.textContent = '';
|
||||
actionModal.style.display = 'block';
|
||||
}
|
||||
|
||||
// Function to update a selection's action
|
||||
function updateSelectionAction(id, action) {
|
||||
console.log(`DEBUG: Updating selection ${id} to action ${action}`);
|
||||
modalMessage.textContent = 'Updating...';
|
||||
|
||||
fetch('/update-selection', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: id,
|
||||
action: action
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update selection');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Selection updated:', data);
|
||||
modalMessage.textContent = 'Updated successfully!';
|
||||
|
||||
// Close the modal after a short delay
|
||||
setTimeout(() => {
|
||||
actionModal.style.display = 'none';
|
||||
loadSelections(); // Reload the selections
|
||||
}, 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating selection:', error);
|
||||
modalMessage.textContent = `Error: ${error.message}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to delete a selection
|
||||
function deleteSelection(id) {
|
||||
console.log('DEBUG: Deleting selection with ID:', id);
|
||||
|
||||
if (!confirm('Are you sure you want to delete this selection?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/delete-selection?id=${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete selection');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Selection deleted:', data);
|
||||
loadSelections(); // Reload the selections
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting selection:', error);
|
||||
alert('Error deleting selection');
|
||||
});
|
||||
}
|
||||
|
||||
// Function to reset the database
|
||||
function resetDatabase() {
|
||||
console.log('DEBUG: Resetting database');
|
||||
resetMessage.textContent = 'Resetting...';
|
||||
|
||||
fetch('/reset-database', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to reset database');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Database reset:', data);
|
||||
resetMessage.textContent = 'Database reset successfully!';
|
||||
|
||||
// Close the modal after a short delay
|
||||
setTimeout(() => {
|
||||
resetModal.style.display = 'none';
|
||||
loadSelections(); // Reload the selections
|
||||
}, 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error resetting database:', error);
|
||||
resetMessage.textContent = `Error: ${error.message}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to get the display name for an action
|
||||
function getActionName(action) {
|
||||
switch(action) {
|
||||
case 'left': return 'Discard';
|
||||
case 'right': return 'Keep';
|
||||
case 'up': return 'Favorite';
|
||||
case 'down': return 'Review';
|
||||
default: return action;
|
||||
}
|
||||
}
|
||||
|
||||
// Add console logging to help debug
|
||||
console.log('History page script loaded');
|
||||
});
|
||||
</script>
|
||||
<script src="js/history.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
94
index.html
94
index.html
@@ -5,45 +5,70 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Swipe App</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<!-- Font Awesome for button icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha512-KfkFx7UiO/8VdM4DJ8GIzQ3pObu7q9gP/yu1ZPTM0u88Z+cIXtA8nKg9ePC60zY+XvKw5xpbIX8zahPszp5C8w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<header class="header">
|
||||
<h1 class="app-title"><i class="fa-solid fa-images"></i> Swiper</h1>
|
||||
<a href="/history.html" class="history-link">View History</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="swipe-container">
|
||||
<div class="image-card" id="current-card">
|
||||
<!-- Image will be loaded here dynamically -->
|
||||
<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%3ELoading...%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Image">
|
||||
<div class="loading-indicator">Loading...</div>
|
||||
<main class="main-section">
|
||||
<div class="swipe-container">
|
||||
<div class="image-card" id="current-card" role="img" aria-label="Image to be swiped">
|
||||
<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%3ELoading...%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Image">
|
||||
<div class="loading-indicator">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="swipe-actions">
|
||||
<div class="action-hint left-hint">Discard</div>
|
||||
<div class="action-hint right-hint">Keep</div>
|
||||
<div class="action-hint up-hint">Favorite</div>
|
||||
<div class="action-hint down-hint">Review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="swipe-actions">
|
||||
<div class="action-hint left-hint">← Swipe Left</div>
|
||||
<div class="action-hint right-hint">Swipe Right →</div>
|
||||
<div class="action-hint up-hint">↑ Swipe Up</div>
|
||||
<div class="action-hint down-hint">Swipe Down ↓</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button id="btn-left" class="action-btn">Discard</button>
|
||||
<button id="btn-right" class="action-btn">Keep</button>
|
||||
<button id="btn-up" class="action-btn">Favorite</button>
|
||||
<button id="btn-down" class="action-btn">Review</button>
|
||||
</div>
|
||||
|
||||
<div class="status-area">
|
||||
<p>Current resolution: Loading...</p>
|
||||
<p id="last-action">Last action: None</p>
|
||||
<div class="swipe-legend">
|
||||
<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 up-color"></span> Up: Favorite</div>
|
||||
<div class="legend-item"><span class="legend-color down-color"></span> Down: Review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="side-panel">
|
||||
<div class="filter-controls">
|
||||
<h4>Orientation</h4>
|
||||
<div class="filter-buttons orientation-filters">
|
||||
<button class="filter-btn active" data-orientation="all">All</button>
|
||||
<button class="filter-btn" data-orientation="portrait">Portrait</button>
|
||||
<button class="filter-btn" data-orientation="landscape">Landscape</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<h4>Actions</h4>
|
||||
<button id="btn-left" class="action-btn" aria-label="Discard">
|
||||
<i class="fa-solid fa-trash"></i><span class="label">Discard</span>
|
||||
</button>
|
||||
<button id="btn-right" class="action-btn" aria-label="Keep">
|
||||
<i class="fa-solid fa-folder-plus"></i><span class="label">Keep</span>
|
||||
</button>
|
||||
<button id="btn-up" class="action-btn" aria-label="Favorite">
|
||||
<i class="fa-solid fa-star"></i><span class="label">Favorite</span>
|
||||
</button>
|
||||
<button id="btn-down" class="action-btn" aria-label="Review">
|
||||
<i class="fa-solid fa-clock"></i><span class="label">Review</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-area" aria-live="polite">
|
||||
<h4>Status</h4>
|
||||
<p id="image-resolution">Resolution: Loading...</p>
|
||||
<p id="last-action">Last action: None</p>
|
||||
<div class="swipe-legend">
|
||||
<div class="legend-item"><span class="legend-color left-color"></span>Discard</div>
|
||||
<div class="legend-item"><span class="legend-color right-color"></span>Keep</div>
|
||||
<div class="legend-item"><span class="legend-color up-color"></span>Favorite</div>
|
||||
<div class="legend-item"><span class="legend-color down-color"></span>Review</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Full-resolution image modal -->
|
||||
@@ -59,6 +84,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="js/main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
186
js/history.js
Normal file
186
js/history.js
Normal file
@@ -0,0 +1,186 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const selectionGrid = document.getElementById('selection-grid');
|
||||
const filterButtons = document.querySelectorAll('.filter-buttons .filter-btn');
|
||||
const orientationButtons = document.querySelectorAll('.orientation-filters .filter-btn');
|
||||
const resolutionFilter = document.getElementById('resolution-filter');
|
||||
const selectAllBtn = document.getElementById('select-all');
|
||||
const deselectAllBtn = document.getElementById('deselect-all');
|
||||
const downloadSelectedBtn = document.getElementById('download-selected');
|
||||
|
||||
const actionModal = document.getElementById('action-modal');
|
||||
const closeActionModal = document.getElementById('close-action-modal');
|
||||
const actionButtons = actionModal.querySelectorAll('.action-btn');
|
||||
const modalPreviewImg = document.getElementById('modal-preview-img');
|
||||
const modalMessage = document.getElementById('modal-message');
|
||||
|
||||
const resetBtn = document.getElementById('reset-db');
|
||||
const resetModal = document.getElementById('reset-modal');
|
||||
const confirmResetBtn = document.getElementById('confirm-reset');
|
||||
const cancelResetBtn = document.getElementById('cancel-reset');
|
||||
const resetMessage = document.getElementById('reset-message');
|
||||
|
||||
let currentFilter = 'all';
|
||||
let currentOrientation = 'all';
|
||||
let currentResolution = 'all';
|
||||
let selectedItems = [];
|
||||
let currentSelectionId = null;
|
||||
|
||||
const loadSelections = () => {
|
||||
selectionGrid.innerHTML = `<div class="no-selections">Loading selections...</div>`;
|
||||
|
||||
fetch('/selections')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.selections && data.selections.length > 0) {
|
||||
populateResolutionFilter(data.selections);
|
||||
renderSelections(data.selections);
|
||||
} else {
|
||||
selectionGrid.innerHTML = '<div class="no-selections">No selections found</div>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading selections:', error);
|
||||
selectionGrid.innerHTML = `<div class="error">Error loading selections: ${error.message}</div>`;
|
||||
});
|
||||
};
|
||||
|
||||
const populateResolutionFilter = (selections) => {
|
||||
const resolutions = [...new Set(selections.map(s => s.resolution))].sort();
|
||||
resolutionFilter.innerHTML = '<option value="all">All Resolutions</option>';
|
||||
resolutions.forEach(resolution => {
|
||||
const option = document.createElement('option');
|
||||
option.value = resolution;
|
||||
option.textContent = resolution;
|
||||
resolutionFilter.appendChild(option);
|
||||
});
|
||||
};
|
||||
|
||||
const renderSelections = (selections) => {
|
||||
selectionGrid.innerHTML = '';
|
||||
|
||||
const filteredSelections = selections.filter(s =>
|
||||
(currentFilter === 'all' || s.action === currentFilter) &&
|
||||
(currentOrientation === 'all' || s.orientation === currentOrientation) &&
|
||||
(currentResolution === 'all' || s.resolution === currentResolution)
|
||||
);
|
||||
|
||||
if (filteredSelections.length === 0) {
|
||||
selectionGrid.innerHTML = '<div class="no-selections">No selections match the current filters</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
filteredSelections.forEach(selection => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'selection-item';
|
||||
item.dataset.id = selection.id;
|
||||
item.innerHTML = `
|
||||
<div class="selection-checkbox-container">
|
||||
<input type="checkbox" class="selection-checkbox">
|
||||
</div>
|
||||
<img src="${selection.image_path}" alt="Selected image" loading="lazy">
|
||||
<div class="selection-action action-${selection.action}">${getActionName(selection.action)}</div>
|
||||
<div class="selection-info">
|
||||
<p>${selection.image_path.split('/').pop()}</p>
|
||||
<p>Resolution: ${selection.resolution}</p>
|
||||
</div>
|
||||
<div class="selection-controls">
|
||||
<button class="control-btn edit-btn">Change</button>
|
||||
<button class="control-btn delete-btn">Remove</button>
|
||||
</div>
|
||||
`;
|
||||
selectionGrid.appendChild(item);
|
||||
});
|
||||
};
|
||||
|
||||
const getActionName = (action) => {
|
||||
const names = { left: 'Discard', right: 'Keep', up: 'Favorite', down: 'Review' };
|
||||
return names[action] || action;
|
||||
};
|
||||
|
||||
const updateDownloadButton = () => {
|
||||
downloadSelectedBtn.disabled = selectedItems.length === 0;
|
||||
downloadSelectedBtn.querySelector('.label').textContent = selectedItems.length > 0 ? `Download (${selectedItems.length})` : 'Download';
|
||||
};
|
||||
|
||||
selectionGrid.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
const selectionItem = target.closest('.selection-item');
|
||||
if (!selectionItem) return;
|
||||
|
||||
const selectionId = selectionItem.dataset.id;
|
||||
const selection = { id: selectionId, image_path: selectionItem.querySelector('img').src };
|
||||
|
||||
if (target.classList.contains('selection-checkbox')) {
|
||||
if (target.checked) {
|
||||
selectionItem.classList.add('selected');
|
||||
selectedItems.push(selection);
|
||||
} else {
|
||||
selectionItem.classList.remove('selected');
|
||||
selectedItems = selectedItems.filter(item => item.id !== selectionId);
|
||||
}
|
||||
updateDownloadButton();
|
||||
} else if (target.classList.contains('edit-btn')) {
|
||||
currentSelectionId = selectionId;
|
||||
modalPreviewImg.src = selection.image_path;
|
||||
actionModal.style.display = 'flex';
|
||||
} else if (target.classList.contains('delete-btn')) {
|
||||
if (confirm('Are you sure you want to delete this selection?')) {
|
||||
// Implement delete functionality
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
filterButtons.forEach(button => button.addEventListener('click', function() {
|
||||
filterButtons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
currentFilter = this.dataset.filter;
|
||||
loadSelections();
|
||||
}));
|
||||
|
||||
orientationButtons.forEach(button => button.addEventListener('click', function() {
|
||||
orientationButtons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
currentOrientation = this.dataset.orientation;
|
||||
loadSelections();
|
||||
}));
|
||||
|
||||
resolutionFilter.addEventListener('change', function() {
|
||||
currentResolution = this.value;
|
||||
loadSelections();
|
||||
});
|
||||
|
||||
selectAllBtn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.selection-checkbox').forEach(cb => cb.checked = true);
|
||||
selectedItems = Array.from(document.querySelectorAll('.selection-item')).map(item => ({id: item.dataset.id, image_path: item.querySelector('img').src}));
|
||||
document.querySelectorAll('.selection-item').forEach(item => item.classList.add('selected'));
|
||||
updateDownloadButton();
|
||||
});
|
||||
|
||||
deselectAllBtn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.selection-checkbox').forEach(cb => cb.checked = false);
|
||||
selectedItems = [];
|
||||
document.querySelectorAll('.selection-item').forEach(item => item.classList.remove('selected'));
|
||||
updateDownloadButton();
|
||||
});
|
||||
|
||||
downloadSelectedBtn.addEventListener('click', () => {
|
||||
const paths = selectedItems.map(item => item.image_path);
|
||||
const query = paths.map(p => `paths=${encodeURIComponent(p)}`).join('&');
|
||||
window.location.href = `/download-selected?${query}`;
|
||||
});
|
||||
|
||||
closeActionModal.addEventListener('click', () => actionModal.style.display = 'none');
|
||||
|
||||
actionButtons.forEach(button => button.addEventListener('click', function() {
|
||||
const action = this.dataset.action;
|
||||
// Implement update action functionality
|
||||
}));
|
||||
|
||||
resetBtn.addEventListener('click', () => resetModal.style.display = 'flex');
|
||||
confirmResetBtn.addEventListener('click', () => {
|
||||
// Implement reset database functionality
|
||||
});
|
||||
cancelResetBtn.addEventListener('click', () => resetModal.style.display = 'none');
|
||||
|
||||
loadSelections();
|
||||
});
|
||||
194
js/main.js
Normal file
194
js/main.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import { showToast, updateImageInfo } from './utils.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const state = {
|
||||
currentImageInfo: null,
|
||||
currentOrientation: 'all',
|
||||
isLoading: false,
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
touchStartTime: 0,
|
||||
hasMoved: false,
|
||||
};
|
||||
|
||||
const card = document.getElementById('current-card');
|
||||
const lastActionText = document.getElementById('last-action');
|
||||
const orientationFilters = document.querySelector('.orientation-filters');
|
||||
const modal = document.getElementById('fullscreen-modal');
|
||||
const fullscreenImage = document.getElementById('fullscreen-image');
|
||||
const closeModal = document.querySelector('.close-modal');
|
||||
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
const performSwipe = (direction) => {
|
||||
if (!state.currentImageInfo) return;
|
||||
|
||||
card.classList.add(`swipe-${direction}`);
|
||||
lastActionText.textContent = `Last action: Swiped ${direction}`;
|
||||
const toastMap = { left: 'Discarded', right: 'Kept', up: 'Favorited', down: 'Marked for review' };
|
||||
showToast(toastMap[direction] || 'Action');
|
||||
|
||||
recordSelection(state.currentImageInfo, direction);
|
||||
|
||||
setTimeout(() => {
|
||||
card.classList.remove(`swipe-${direction}`);
|
||||
loadNewImage();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const recordSelection = (imageInfo, action) => {
|
||||
fetch('/selection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_path: imageInfo.path,
|
||||
resolution: imageInfo.resolution,
|
||||
action,
|
||||
}),
|
||||
}).catch(error => console.error('Error recording selection:', error));
|
||||
};
|
||||
|
||||
const loadNewImage = () => {
|
||||
if (state.isLoading) return;
|
||||
state.isLoading = true;
|
||||
card.classList.add('loading');
|
||||
|
||||
fetch(`/random-image?orientation=${state.currentOrientation}&t=${new Date().getTime()}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
state.isLoading = false;
|
||||
card.classList.remove('loading');
|
||||
if (data && data.path) {
|
||||
state.currentImageInfo = data;
|
||||
const cardImage = card.querySelector('img');
|
||||
cardImage.src = data.path;
|
||||
updateImageInfo(data);
|
||||
adjustContainerToImage(data.orientation);
|
||||
} else {
|
||||
card.innerHTML = `<div class="no-images-message">${data.message || 'No more images.'}</div>`;
|
||||
state.currentImageInfo = null;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching image:', error);
|
||||
state.isLoading = false;
|
||||
card.classList.remove('loading');
|
||||
card.innerHTML = '<div class="no-images-message">Error loading image.</div>';
|
||||
});
|
||||
};
|
||||
|
||||
const adjustContainerToImage = (orientation) => {
|
||||
const container = document.querySelector('.swipe-container');
|
||||
if (window.innerWidth < 992) { // Only on desktop
|
||||
container.style.transition = 'all 0.5s ease-in-out';
|
||||
if (orientation === 'landscape') {
|
||||
container.style.flex = '4';
|
||||
} else {
|
||||
container.style.flex = '2';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (x, y) => {
|
||||
state.isDragging = true;
|
||||
state.startX = x;
|
||||
state.startY = y;
|
||||
state.hasMoved = false;
|
||||
state.touchStartTime = Date.now();
|
||||
card.classList.add('swiping');
|
||||
};
|
||||
|
||||
const handlePointerMove = (x, y) => {
|
||||
if (!state.isDragging) return;
|
||||
|
||||
state.moveX = x - state.startX;
|
||||
state.moveY = y - state.startY;
|
||||
|
||||
if (Math.abs(state.moveX) > 10 || Math.abs(state.moveY) > 10) {
|
||||
state.hasMoved = true;
|
||||
}
|
||||
|
||||
card.style.transform = `translate(${state.moveX}px, ${state.moveY}px) rotate(${state.moveX * 0.05}deg)`;
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (!state.isDragging) return;
|
||||
state.isDragging = false;
|
||||
card.classList.remove('swiping');
|
||||
|
||||
const absX = Math.abs(state.moveX);
|
||||
const absY = Math.abs(state.moveY);
|
||||
|
||||
if (state.hasMoved && (absX > SWIPE_THRESHOLD || absY > SWIPE_THRESHOLD)) {
|
||||
if (absX > absY) {
|
||||
performSwipe(state.moveX > 0 ? 'right' : 'left');
|
||||
} else {
|
||||
performSwipe(state.moveY > 0 ? 'down' : 'up');
|
||||
}
|
||||
} else {
|
||||
card.style.transform = '';
|
||||
}
|
||||
|
||||
state.moveX = 0;
|
||||
state.moveY = 0;
|
||||
};
|
||||
|
||||
card.addEventListener('mousedown', e => handlePointerDown(e.clientX, e.clientY));
|
||||
document.addEventListener('mousemove', e => handlePointerMove(e.clientX, e.clientY));
|
||||
document.addEventListener('mouseup', () => handlePointerUp());
|
||||
|
||||
card.addEventListener('touchstart', e => handlePointerDown(e.touches[0].clientX, e.touches[0].clientY), { passive: true });
|
||||
card.addEventListener('touchmove', e => handlePointerMove(e.touches[0].clientX, e.touches[0].clientY), { passive: true });
|
||||
card.addEventListener('touchend', () => handlePointerUp());
|
||||
|
||||
document.getElementById('btn-left').addEventListener('click', () => performSwipe('left'));
|
||||
document.getElementById('btn-right').addEventListener('click', () => performSwipe('right'));
|
||||
document.getElementById('btn-up').addEventListener('click', () => performSwipe('up'));
|
||||
document.getElementById('btn-down').addEventListener('click', () => performSwipe('down'));
|
||||
|
||||
orientationFilters.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === 'BUTTON' && !e.target.classList.contains('active')) {
|
||||
orientationFilters.querySelector('.active').classList.remove('active');
|
||||
e.target.classList.add('active');
|
||||
state.currentOrientation = e.target.dataset.orientation;
|
||||
loadNewImage();
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
if (!state.hasMoved && state.currentImageInfo) {
|
||||
fullscreenImage.src = state.currentImageInfo.path;
|
||||
document.getElementById('modal-resolution').textContent = `Resolution: ${state.currentImageInfo.resolution}`;
|
||||
document.getElementById('modal-filename').textContent = `Filename: ${state.currentImageInfo.filename || 'N/A'}`;
|
||||
document.getElementById('modal-creation-date').textContent = `Creation Date: ${state.currentImageInfo.creation_date || 'N/A'}`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
|
||||
closeModal.addEventListener('click', () => modal.style.display = 'none');
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (modal.style.display === 'flex' && e.key === 'Escape') {
|
||||
modal.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (modal.style.display !== 'flex') {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': performSwipe('left'); break;
|
||||
case 'ArrowRight': performSwipe('right'); break;
|
||||
case 'ArrowUp': performSwipe('up'); break;
|
||||
case 'ArrowDown': performSwipe('down'); break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loadNewImage();
|
||||
});
|
||||
14
js/utils.js
Normal file
14
js/utils.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export function showToast(message) {
|
||||
const toastEl = document.getElementById('toast');
|
||||
if (!toastEl) return;
|
||||
toastEl.textContent = message;
|
||||
toastEl.classList.add('show');
|
||||
setTimeout(() => toastEl.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
export function updateImageInfo(data) {
|
||||
const resolutionEl = document.getElementById('image-resolution');
|
||||
if (resolutionEl) {
|
||||
resolutionEl.textContent = `Resolution: ${data.resolution || 'N/A'}`;
|
||||
}
|
||||
}
|
||||
635
script.js
635
script.js
@@ -1,635 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const card = document.getElementById('current-card');
|
||||
const lastActionText = document.getElementById('last-action');
|
||||
const leftHint = document.querySelector('.left-hint');
|
||||
const rightHint = document.querySelector('.right-hint');
|
||||
const upHint = document.querySelector('.up-hint');
|
||||
const downHint = document.querySelector('.down-hint');
|
||||
const swipeContainer = document.querySelector('.swipe-container');
|
||||
|
||||
console.log('DOM Content Loaded - initializing app');
|
||||
|
||||
// Image cache for preloading
|
||||
const imageCache = {
|
||||
images: [], // Will store objects with {path, data, element}
|
||||
maxSize: 2, // Cache up to 2 images
|
||||
|
||||
// Add an image to the cache
|
||||
add: function(imageData) {
|
||||
// Create a new Image element for preloading
|
||||
const img = new Image();
|
||||
img.src = imageData.path;
|
||||
|
||||
// Add to the cache
|
||||
this.images.push({
|
||||
path: imageData.path,
|
||||
data: imageData,
|
||||
element: img
|
||||
});
|
||||
|
||||
console.log(`Added image to cache: ${imageData.path}`);
|
||||
|
||||
// Trim cache if it exceeds max size
|
||||
if (this.images.length > this.maxSize) {
|
||||
this.images.shift(); // Remove oldest image
|
||||
}
|
||||
},
|
||||
|
||||
// Get an image from cache if available
|
||||
get: function(path) {
|
||||
const cachedImage = this.images.find(img => img.path === path);
|
||||
if (cachedImage) {
|
||||
console.log(`Cache hit for: ${path}`);
|
||||
// Remove this image from the cache since we're using it
|
||||
this.images = this.images.filter(img => img.path !== path);
|
||||
return cachedImage;
|
||||
}
|
||||
console.log(`Cache miss for: ${path}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Detect if we're on a mobile device
|
||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
console.log('Mobile view detection:', isMobile ? 'mobile' : 'desktop');
|
||||
|
||||
// Apply mobile-specific behaviors
|
||||
if (isMobile) {
|
||||
console.log('Applying mobile-specific behaviors');
|
||||
// Show swipe hints briefly on page load to educate users
|
||||
setTimeout(() => {
|
||||
showAllHints();
|
||||
setTimeout(hideAllHints, 3000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Important: Load the first image regardless of viewport size
|
||||
console.log('Triggering initial image load');
|
||||
// Wait a short moment to ensure all initialization is complete
|
||||
setTimeout(() => {
|
||||
loadNewImage();
|
||||
}, 300);
|
||||
|
||||
// Adjust swipe container height to fill available space
|
||||
function adjustSwipeContainerHeight() {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const containerTop = swipeContainer.getBoundingClientRect().top;
|
||||
const statusAreaHeight = document.querySelector('.status-area').offsetHeight;
|
||||
const actionButtonsHeight = document.querySelector('.action-buttons') ?
|
||||
document.querySelector('.action-buttons').offsetHeight : 0;
|
||||
const footerSpace = 20; // Extra space for padding/margin
|
||||
|
||||
// Calculate available height
|
||||
const availableHeight = viewportHeight - containerTop - statusAreaHeight -
|
||||
actionButtonsHeight - footerSpace;
|
||||
|
||||
// Set minimum height to ensure it's usable
|
||||
const minHeight = isMobile ? '60vh' : '400px';
|
||||
swipeContainer.style.minHeight = `max(${availableHeight}px, ${minHeight})`;
|
||||
|
||||
console.log('Adjusted container height: ', swipeContainer.style.minHeight);
|
||||
}
|
||||
|
||||
// Run on load and on resize
|
||||
adjustSwipeContainerHeight();
|
||||
window.addEventListener('resize', adjustSwipeContainerHeight);
|
||||
|
||||
// Make sure the first image loads after layout calculations are done
|
||||
window.addEventListener('load', function() {
|
||||
console.log('Window fully loaded - requesting first image');
|
||||
loadNewImage();
|
||||
});
|
||||
|
||||
// Add the animation class to the initial card
|
||||
setTimeout(() => {
|
||||
card.classList.add('new-card');
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
|
||||
// Modal elements
|
||||
const modal = document.getElementById('fullscreen-modal');
|
||||
const fullscreenImage = document.getElementById('fullscreen-image');
|
||||
const closeModal = document.querySelector('.close-modal');
|
||||
const modalResolution = document.getElementById('modal-resolution');
|
||||
const modalFilename = document.getElementById('modal-filename');
|
||||
const modalCreationDate = document.getElementById('modal-creation-date');
|
||||
|
||||
// Current image information
|
||||
let currentImageInfo = null;
|
||||
|
||||
// Button event listeners
|
||||
document.getElementById('btn-left').addEventListener('click', () => performSwipe('left'));
|
||||
document.getElementById('btn-right').addEventListener('click', () => performSwipe('right'));
|
||||
document.getElementById('btn-up').addEventListener('click', () => performSwipe('up'));
|
||||
document.getElementById('btn-down').addEventListener('click', () => performSwipe('down'));
|
||||
|
||||
// Touch start time for distinguishing between swipe and tap
|
||||
let touchStartTime = 0;
|
||||
|
||||
// Touch variables
|
||||
let startX, startY, moveX, moveY;
|
||||
let isDragging = false;
|
||||
const swipeThreshold = 150; // Increased minimum distance for a swipe to be registered
|
||||
let hasMoved = false; // Track if significant movement occurred
|
||||
|
||||
// Touch event handlers with passive: false for better mobile performance
|
||||
card.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
card.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
card.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||
|
||||
// Mouse event handlers (for desktop testing)
|
||||
card.addEventListener('mousedown', handleMouseDown, false);
|
||||
document.addEventListener('mousemove', handleMouseMove, false);
|
||||
document.addEventListener('mouseup', handleMouseUp, false);
|
||||
|
||||
// Click handler for viewing full-resolution image
|
||||
card.addEventListener('click', handleCardClick);
|
||||
|
||||
// Close modal when clicking the close button
|
||||
closeModal.addEventListener('click', () => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Close modal when clicking outside the image
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal with escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.style.display === 'block') {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function handleTouchStart(e) {
|
||||
// Store the initial position and set the dragging flag
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
isDragging = true;
|
||||
hasMoved = false; // Reset movement tracking
|
||||
card.classList.add('swiping');
|
||||
|
||||
// Record touch start time to distinguish between tap and swipe
|
||||
touchStartTime = new Date().getTime();
|
||||
|
||||
// Prevent default to avoid scrolling while swiping
|
||||
e.preventDefault();
|
||||
|
||||
// Check if we're on mobile
|
||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
|
||||
// Show swipe hints on mobile
|
||||
if (isMobile) {
|
||||
showAllHints();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
moveX = touch.clientX - startX;
|
||||
moveY = touch.clientY - startY;
|
||||
|
||||
// Check if we've moved significantly
|
||||
const absX = Math.abs(moveX);
|
||||
const absY = Math.abs(moveY);
|
||||
if (Math.max(absX, absY) > 20) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
// Calculate a fade factor based on distance (further = more transparent)
|
||||
const maxDistance = Math.max(window.innerWidth, window.innerHeight) * 0.4;
|
||||
const distance = Math.sqrt(moveX * moveX + moveY * moveY);
|
||||
const fadeAmount = Math.min(0.7, distance / maxDistance);
|
||||
|
||||
// Apply transform with reduced rotation and opacity based on swipe distance
|
||||
card.style.transform = `translate(${moveX}px, ${moveY}px) rotate(${moveX * 0.02}deg)`;
|
||||
card.style.opacity = `${1 - fadeAmount}`;
|
||||
|
||||
// Show appropriate hint based on swipe direction
|
||||
updateHints(moveX, moveY);
|
||||
|
||||
// Add visual feedback based on swipe direction
|
||||
updateVisualFeedback(moveX, moveY);
|
||||
|
||||
// Prevent default to avoid scrolling while swiping
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Add visual feedback based on swipe direction
|
||||
function updateVisualFeedback(moveX, moveY) {
|
||||
// Reset all borders
|
||||
card.style.boxShadow = '0 10px 20px rgba(0, 0, 0, 0.2)';
|
||||
|
||||
const absX = Math.abs(moveX);
|
||||
const absY = Math.abs(moveY);
|
||||
|
||||
// Only show feedback if we've moved enough
|
||||
if (Math.max(absX, absY) < swipeThreshold / 2) return;
|
||||
|
||||
if (absX > absY) {
|
||||
// Horizontal swipe
|
||||
if (moveX > 0) {
|
||||
// Right swipe - green glow
|
||||
card.style.boxShadow = '0 0 20px 5px rgba(46, 213, 115, 0.7)';
|
||||
} else {
|
||||
// Left swipe - red glow
|
||||
card.style.boxShadow = '0 0 20px 5px rgba(255, 71, 87, 0.7)';
|
||||
}
|
||||
} else {
|
||||
// Vertical swipe
|
||||
if (moveY > 0) {
|
||||
// Down swipe - yellow glow
|
||||
card.style.boxShadow = '0 0 20px 5px rgba(255, 165, 2, 0.7)';
|
||||
} else {
|
||||
// Up swipe - blue glow
|
||||
card.style.boxShadow = '0 0 20px 5px rgba(30, 144, 255, 0.7)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
// Calculate touch duration
|
||||
const touchEndTime = new Date().getTime();
|
||||
const touchDuration = touchEndTime - touchStartTime;
|
||||
|
||||
// Determine if this was a tap (short touch with minimal movement)
|
||||
const absX = Math.abs(moveX || 0);
|
||||
const absY = Math.abs(moveY || 0);
|
||||
// More generous tap detection - increased movement threshold to 30px
|
||||
const isTap = touchDuration < 300 && Math.max(absX, absY) < 30;
|
||||
|
||||
isDragging = false;
|
||||
|
||||
if (isTap || !hasMoved) {
|
||||
// This was a tap or minimal movement, not a swipe
|
||||
resetCardPosition();
|
||||
handleCardClick(e);
|
||||
} else if (Math.max(absX, absY) > swipeThreshold && touchDuration > 100) {
|
||||
// This was a swipe
|
||||
if (absX > absY) {
|
||||
// Horizontal swipe
|
||||
if (moveX > 0) {
|
||||
performSwipe('right');
|
||||
} else {
|
||||
performSwipe('left');
|
||||
}
|
||||
} else {
|
||||
// Vertical swipe
|
||||
if (moveY > 0) {
|
||||
performSwipe('down');
|
||||
} else {
|
||||
performSwipe('up');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset card position if swipe wasn't strong enough
|
||||
resetCardPosition();
|
||||
}
|
||||
|
||||
// Hide all hints
|
||||
hideAllHints();
|
||||
}
|
||||
|
||||
function handleMouseDown(e) {
|
||||
// Store the initial position and set the dragging flag
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
isDragging = true;
|
||||
card.classList.add('swiping');
|
||||
|
||||
// Prevent default to avoid text selection during drag
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
moveX = e.clientX - startX;
|
||||
moveY = e.clientY - startY;
|
||||
|
||||
// Apply transform to the card
|
||||
card.style.transform = `translate(${moveX}px, ${moveY}px) rotate(${moveX * 0.1}deg)`;
|
||||
|
||||
// Show appropriate hint based on direction
|
||||
updateHints(moveX, moveY);
|
||||
}
|
||||
|
||||
function handleMouseUp(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
// Determine if this was a click (minimal movement) or a swipe
|
||||
const absX = Math.abs(moveX || 0);
|
||||
const absY = Math.abs(moveY || 0);
|
||||
|
||||
isDragging = false;
|
||||
|
||||
if (Math.max(absX, absY) > swipeThreshold) {
|
||||
if (absX > absY) {
|
||||
// Horizontal swipe
|
||||
if (moveX > 0) {
|
||||
performSwipe('right');
|
||||
} else {
|
||||
performSwipe('left');
|
||||
}
|
||||
} else {
|
||||
// Vertical swipe
|
||||
if (moveY > 0) {
|
||||
performSwipe('down');
|
||||
} else {
|
||||
performSwipe('up');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset card position if swipe wasn't strong enough
|
||||
resetCardPosition();
|
||||
// We don't trigger click here because the card already has a click event listener
|
||||
}
|
||||
|
||||
// Hide all hints
|
||||
hideAllHints();
|
||||
}
|
||||
|
||||
function updateHints(moveX, moveY) {
|
||||
hideAllHints();
|
||||
|
||||
const absX = Math.abs(moveX);
|
||||
const absY = Math.abs(moveY);
|
||||
|
||||
if (absX > absY) {
|
||||
// Horizontal movement is dominant
|
||||
if (moveX > 0) {
|
||||
rightHint.style.opacity = '1';
|
||||
} else {
|
||||
leftHint.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
// Vertical movement is dominant
|
||||
if (moveY > 0) {
|
||||
downHint.style.opacity = '1';
|
||||
} else {
|
||||
upHint.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideAllHints() {
|
||||
leftHint.style.opacity = '0';
|
||||
rightHint.style.opacity = '0';
|
||||
upHint.style.opacity = '0';
|
||||
downHint.style.opacity = '0';
|
||||
}
|
||||
|
||||
function resetCardPosition() {
|
||||
card.classList.remove('swiping');
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '1';
|
||||
}
|
||||
|
||||
function performSwipe(direction) {
|
||||
// Add the appropriate swipe class
|
||||
card.classList.add(`swipe-${direction}`);
|
||||
|
||||
// Start fading out immediately
|
||||
card.style.opacity = '0.8';
|
||||
|
||||
// Update the last action text
|
||||
lastActionText.textContent = `Last action: Swiped ${direction}`;
|
||||
|
||||
// Apply a more dramatic exit animation based on direction
|
||||
if (direction === 'left') {
|
||||
card.style.transform = 'translateX(-350%) rotate(-15deg)';
|
||||
} else if (direction === 'right') {
|
||||
card.style.transform = 'translateX(350%) rotate(15deg)';
|
||||
} else if (direction === 'up') {
|
||||
card.style.transform = 'translateY(-350%) rotate(5deg)';
|
||||
} else if (direction === 'down') {
|
||||
card.style.transform = 'translateY(350%) rotate(-5deg)';
|
||||
}
|
||||
|
||||
// Record the selection in the database if we have a current image
|
||||
if (currentImageInfo) {
|
||||
recordSelection(currentImageInfo, direction);
|
||||
}
|
||||
|
||||
// After animation completes, reset and load a new image
|
||||
setTimeout(() => {
|
||||
card.classList.remove(`swipe-${direction}`);
|
||||
card.classList.remove('swiping');
|
||||
card.style.transform = '';
|
||||
|
||||
// Position the card offscreen to the left (for consistent entry animation)
|
||||
card.style.transform = 'translateX(-100%)';
|
||||
card.style.opacity = '0';
|
||||
|
||||
// Load a new random image from our server
|
||||
loadNewImage();
|
||||
}, 200); // Reduced from 300ms to 200ms for faster transitions
|
||||
}
|
||||
|
||||
// Function to record a selection in the database
|
||||
function recordSelection(imageInfo, action) {
|
||||
// Create the data to send
|
||||
const data = {
|
||||
path: imageInfo.path,
|
||||
resolution: imageInfo.resolution,
|
||||
action: action
|
||||
};
|
||||
|
||||
// Send the data to the server
|
||||
fetch('/record-selection', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to record selection');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Selection recorded:', data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error recording selection:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to prefetch images and store them in cache
|
||||
function prefetchImages(count = 1) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Use a small delay between requests to avoid overwhelming the server
|
||||
setTimeout(() => {
|
||||
console.log(`Prefetching image ${i+1} of ${count}`);
|
||||
fetch('/random-image?t=' + new Date().getTime())
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to fetch image for prefetching');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Add the fetched image to our cache
|
||||
imageCache.add(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error prefetching image:', error);
|
||||
});
|
||||
}, i * 200); // Stagger requests by 200ms
|
||||
}
|
||||
}
|
||||
|
||||
// Function to load a new image from our local server
|
||||
function loadNewImage() {
|
||||
console.log('loadNewImage called');
|
||||
|
||||
// Show loading state
|
||||
const img = card.querySelector('img');
|
||||
img.style.opacity = '0';
|
||||
|
||||
// Remove all animation classes to reset
|
||||
card.classList.remove('new-card');
|
||||
card.classList.remove('new-card-mobile');
|
||||
|
||||
// Ensure card is positioned off-screen to the left to start
|
||||
// This guarantees entry from the left regardless of previous swipe direction
|
||||
card.style.transform = 'translateX(-100%)';
|
||||
card.style.transition = 'none'; // Disable transition when setting initial position
|
||||
|
||||
// Check if mobile view is active
|
||||
const currentlyMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
console.log('Current view:', currentlyMobile ? 'mobile' : 'desktop');
|
||||
|
||||
// Try to get an image from cache first
|
||||
if (imageCache.images.length > 0) {
|
||||
console.log('Using cached image');
|
||||
const cachedImage = imageCache.images.shift();
|
||||
displayImage(cachedImage.data);
|
||||
|
||||
// Prefetch a new image to replace the one we just used
|
||||
prefetchImages(1);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('No cached images available, fetching from server...');
|
||||
// Fetch a random image from our API with a cache-busting parameter
|
||||
fetch('/random-image?t=' + new Date().getTime())
|
||||
.then(response => {
|
||||
console.log('Fetch response received:', response.status);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch image: ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Image data received:', data);
|
||||
displayImage(data);
|
||||
|
||||
// After displaying the first image, prefetch more for the cache
|
||||
if (imageCache.images.length < imageCache.maxSize) {
|
||||
prefetchImages(imageCache.maxSize - imageCache.images.length);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching random image:', error);
|
||||
// Handle the error (e.g., display an error message)
|
||||
const statusElement = document.querySelector('.status-area p:first-child');
|
||||
statusElement.textContent = 'Error: Failed to fetch image';
|
||||
|
||||
// Try again after a delay
|
||||
setTimeout(() => {
|
||||
console.log('Retrying image load after error...');
|
||||
loadNewImage();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to display an image (from cache or fresh fetch)
|
||||
function displayImage(data) {
|
||||
// Store current image info
|
||||
currentImageInfo = data;
|
||||
|
||||
// Extract filename from path
|
||||
const pathParts = data.path.split('/');
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
currentImageInfo.filename = filename;
|
||||
currentImageInfo.creation_date = data.creation_date || 'Unknown';
|
||||
|
||||
// Check if mobile view is active
|
||||
const currentlyMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
|
||||
// Get the image element
|
||||
const img = card.querySelector('img');
|
||||
|
||||
// Set the image source
|
||||
img.src = data.path;
|
||||
|
||||
// Set up the onload handler
|
||||
img.onload = function() {
|
||||
console.log('Image loaded successfully');
|
||||
|
||||
// Force a reflow to ensure animation works
|
||||
void card.offsetWidth;
|
||||
|
||||
// Re-enable transitions for the animation with faster timing
|
||||
card.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
|
||||
|
||||
// Make the image visible
|
||||
img.style.opacity = '1';
|
||||
|
||||
// Different animation approach for mobile vs desktop
|
||||
if (currentlyMobile) {
|
||||
// Simple fade-in for mobile - more reliable
|
||||
card.style.transform = 'translateX(0)';
|
||||
card.classList.add('new-card-mobile');
|
||||
} else {
|
||||
// Slide-in animation for desktop
|
||||
card.classList.add('new-card');
|
||||
}
|
||||
};
|
||||
|
||||
// Add error handling for image loading
|
||||
img.onerror = function() {
|
||||
console.error('Failed to load image:', data.path);
|
||||
img.src = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22400%22%20height%3D%22400%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%22400%22%20height%3D%22400%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Ctext%20x%3D%22200%22%20y%3D%22200%22%20font-size%3D%2220%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20fill%3D%22%23999%22%3EError Loading Image%3C%2Ftext%3E%3C%2Fsvg%3E';
|
||||
img.style.opacity = '1';
|
||||
|
||||
// Try loading a new image after a delay
|
||||
setTimeout(loadNewImage, 2000);
|
||||
};
|
||||
|
||||
// Update status with resolution info
|
||||
const statusElement = document.querySelector('.status-area p:first-child');
|
||||
statusElement.textContent = `Current resolution: ${data.resolution}`;
|
||||
}
|
||||
|
||||
// Function to handle card click for viewing full-resolution image
|
||||
function handleCardClick(e) {
|
||||
// Only process click if we have image info and we're not in the middle of a swipe
|
||||
if (!currentImageInfo || card.classList.contains('swiping')) return;
|
||||
|
||||
// Prevent click from propagating (important for touch devices)
|
||||
if (e) e.stopPropagation();
|
||||
|
||||
// Set the full-resolution image source
|
||||
fullscreenImage.src = currentImageInfo.path;
|
||||
|
||||
// Update modal info
|
||||
modalResolution.textContent = `Resolution: ${currentImageInfo.resolution}`;
|
||||
modalFilename.textContent = `Filename: ${currentImageInfo.filename || 'Unknown'}`;
|
||||
modalCreationDate.textContent = `Creation Date: ${currentImageInfo.creation_date || 'Unknown'}`;
|
||||
|
||||
// Display the modal
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
// Load initial image
|
||||
loadNewImage();
|
||||
});
|
||||
85
sort_images.py
Normal file
85
sort_images.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import shutil
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
# The root directory where the original images are stored.
|
||||
# IMPORTANT: This must match the IMAGE_DIR in your app.py
|
||||
IMAGE_DIR = "/mnt/secret-items/sd-outputs/Sorted/Images/Portrait"
|
||||
|
||||
# The directory where the sorted images will be moved.
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
|
||||
|
||||
# The path to the selections database.
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image_selections.db")
|
||||
|
||||
def sort_swiped_images():
|
||||
"""Reads the database, moves swiped files into action-based folders, and cleans the DB."""
|
||||
print(f"Connecting to database at {DB_PATH}...")
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Error: Database file not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get all selections from the database
|
||||
cursor.execute("SELECT id, image_path, action FROM image_selections")
|
||||
selections = cursor.fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"Error querying database: {e}")
|
||||
print("This might mean the 'image_selections' table doesn't exist or is empty.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if not selections:
|
||||
print("No swiped images found in the database to sort.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f"Found {len(selections)} swiped images to sort.")
|
||||
moved_count = 0
|
||||
|
||||
for selection_id, image_path, action in selections:
|
||||
# The image_path from the DB might have a leading '/images/' which we need to remove.
|
||||
# It's safer to handle both cases.
|
||||
clean_image_path = image_path.replace('/images/', '', 1) if image_path.startswith('/images/') else image_path
|
||||
|
||||
source_path = os.path.join(IMAGE_DIR, clean_image_path)
|
||||
|
||||
if not os.path.exists(source_path):
|
||||
print(f"Warning: Source file not found, skipping: {source_path}")
|
||||
# We should still remove this dangling DB record
|
||||
cursor.execute("DELETE FROM image_selections WHERE id = ?", (selection_id,))
|
||||
cursor.execute("DELETE FROM image_metadata WHERE path = ?", (clean_image_path,))
|
||||
print(f"Removed dangling database entry for {clean_image_path}")
|
||||
continue
|
||||
|
||||
# Create the destination folder based on the action
|
||||
destination_folder = os.path.join(OUTPUT_DIR, action)
|
||||
os.makedirs(destination_folder, exist_ok=True)
|
||||
|
||||
destination_path = os.path.join(destination_folder, os.path.basename(clean_image_path))
|
||||
|
||||
try:
|
||||
print(f"Moving '{clean_image_path}' to '{action}' folder...")
|
||||
shutil.move(source_path, destination_path)
|
||||
moved_count += 1
|
||||
|
||||
# If move was successful, remove the records from the database
|
||||
cursor.execute("DELETE FROM image_selections WHERE id = ?", (selection_id,))
|
||||
cursor.execute("DELETE FROM image_metadata WHERE path = ?", (clean_image_path,))
|
||||
print(f"Successfully moved and removed DB entries for {clean_image_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error moving file {source_path}: {e}")
|
||||
|
||||
# Commit all changes to the database
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\nSorting complete. Moved {moved_count} images.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
sort_swiped_images()
|
||||
540
styles.css
540
styles.css
@@ -1,21 +1,33 @@
|
||||
:root {
|
||||
--primary-color: #1e90ff;
|
||||
--success-color: #2ed573;
|
||||
--danger-color: #ff4757;
|
||||
--warning-color: #ffa502;
|
||||
--light-color: #f5f5f5;
|
||||
--dark-color: #333;
|
||||
--background-color: #f0f2f5;
|
||||
--card-background: #ffffff;
|
||||
--text-color: #333;
|
||||
--border-radius: 12px;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Arial', sans-serif;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -25,94 +37,77 @@ html, body {
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-top: -10px;
|
||||
margin-bottom: 10px;
|
||||
.app-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.app-title i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.history-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 12px;
|
||||
background-color: #333;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.3s;
|
||||
transition: background-color 0.3s, transform 0.2s;
|
||||
}
|
||||
|
||||
.history-link:hover {
|
||||
background-color: #555;
|
||||
background-color: #1c86e3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.main-section {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.swipe-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
margin-bottom: 20px;
|
||||
flex: 3;
|
||||
min-height: 70vh;
|
||||
perspective: 1000px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--card-background);
|
||||
box-shadow: var(--shadow);
|
||||
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;
|
||||
border-width: 6px;
|
||||
border-left-color: var(--danger-color);
|
||||
border-right-color: var(--success-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-bottom-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.image-card {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s ease-out;
|
||||
transition: transform 0.5s ease-out, opacity 0.5s ease-out;
|
||||
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 */
|
||||
background-color: var(--card-background);
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-card img {
|
||||
@@ -120,17 +115,19 @@ html, body {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
.loading-indicator, .no-images-message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #999;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image-card.loading .loading-indicator {
|
||||
@@ -138,126 +135,111 @@ html, body {
|
||||
}
|
||||
|
||||
.image-card.swiping {
|
||||
transition: none; /* Remove transition during active swiping */
|
||||
}
|
||||
|
||||
.image-card.swipe-left {
|
||||
transform: translateX(-350%) rotate(-15deg);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
.image-card.swipe-right {
|
||||
transform: translateX(350%) rotate(15deg);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
.image-card.swipe-up {
|
||||
transform: translateY(-350%) rotate(5deg);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
.image-card.swipe-down {
|
||||
transform: translateY(350%) rotate(-5deg);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease-out, opacity 0.25s ease-out;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
position: absolute;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.left-hint {
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #ff4757;
|
||||
.left-hint { left: 20px; top: 50%; transform: translateY(-50%); }
|
||||
.right-hint { right: 20px; top: 50%; transform: translateY(-50%); }
|
||||
.up-hint { top: 20px; left: 50%; transform: translateX(-50%); }
|
||||
.down-hint { bottom: 20px; left: 50%; transform: translateX(-50%); }
|
||||
|
||||
.side-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.right-hint {
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #2ed573;
|
||||
.filter-controls, .action-buttons, .status-area {
|
||||
background-color: var(--card-background);
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.up-hint {
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #1e90ff;
|
||||
.filter-controls h4, .action-buttons h4, .status-area h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: var(--dark-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.down-hint {
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #ffa502;
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 15px 0 20px;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 12px 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
margin: 0 5px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#btn-left {
|
||||
background-color: #ff4757;
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#btn-right {
|
||||
background-color: #2ed573;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#btn-up {
|
||||
background-color: #1e90ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#btn-down {
|
||||
background-color: #ffa502;
|
||||
color: white;
|
||||
}
|
||||
#btn-left { background-color: var(--danger-color); }
|
||||
#btn-right { background-color: var(--success-color); }
|
||||
#btn-up { background-color: var(--primary-color); }
|
||||
#btn-down { background-color: var(--warning-color); }
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.status-area {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#last-action {
|
||||
@@ -267,9 +249,8 @@ html, body {
|
||||
}
|
||||
|
||||
.swipe-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -285,82 +266,34 @@ html, body {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.left-color {
|
||||
background-color: #ff4757;
|
||||
.left-color { background-color: var(--danger-color); }
|
||||
.right-color { background-color: var(--success-color); }
|
||||
.up-color { background-color: var(--primary-color); }
|
||||
.down-color { background-color: var(--warning-color); }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--border-radius);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
z-index: 2000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.right-color {
|
||||
background-color: #2ed573;
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
|
||||
.up-color {
|
||||
background-color: #1e90ff;
|
||||
}
|
||||
|
||||
.down-color {
|
||||
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;
|
||||
position: fixed;
|
||||
@@ -371,17 +304,13 @@ html, body {
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
overflow: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
max-width: 1200px;
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -390,55 +319,146 @@ html, body {
|
||||
max-width: 100%;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 20px;
|
||||
top: 15px;
|
||||
right: 30px;
|
||||
color: white;
|
||||
font-size: 35px;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.close-modal:hover,
|
||||
.close-modal:focus {
|
||||
color: #bbb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.modal-info {
|
||||
margin-top: 15px;
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Make the card clickable */
|
||||
.image-card {
|
||||
cursor: pointer;
|
||||
.history-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image-card::after {
|
||||
content: '🔍';
|
||||
.selection-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.selection-item {
|
||||
background-color: var(--card-background);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.selection-item.selected {
|
||||
box-shadow: 0 0 0 3px var(--primary-color), var(--shadow);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.selection-item img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
padding: 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.selection-action {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
color: #333;
|
||||
padding: 5px 10px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.action-left { background-color: var(--danger-color); }
|
||||
.action-right { background-color: var(--success-color); }
|
||||
.action-up { background-color: var(--primary-color); }
|
||||
.action-down { background-color: var(--warning-color); }
|
||||
|
||||
.selection-checkbox-container {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.image-card:hover::after {
|
||||
.selection-item:hover .selection-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
.main-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
.side-panel {
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
.action-buttons {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: var(--card-background);
|
||||
padding: 10px;
|
||||
box-shadow: 0 -4px 12px rgba(0,0,0,0.1);
|
||||
z-index: 1500;
|
||||
flex-direction: row;
|
||||
border-radius: 0;
|
||||
}
|
||||
.swipe-container {
|
||||
min-height: 60vh;
|
||||
}
|
||||
.swipe-legend {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user