This commit is contained in:
Aodhan
2025-06-20 22:27:24 +01:00
parent c09461f58f
commit 324a21800a
10 changed files with 1022 additions and 1866 deletions

282
app.py
View File

@@ -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
View File

@@ -0,0 +1 @@
// This file will contain the button component.

View File

@@ -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">&times;</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>

View File

@@ -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
View 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
View 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
View 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
View File

@@ -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
View 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()

View File

@@ -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;
}
}