diff --git a/config.py b/config.py index 64d0bfc..3411e6b 100644 --- a/config.py +++ b/config.py @@ -8,31 +8,22 @@ import os # Base directory of the repo (this file lives in the project root) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -# Paths to the folders that contain source images. Can be overridden at runtime -# via the IMAGE_DIRS environment variable (colon-separated paths). -_IMAGE_DIRS_DEFAULT = [ - "/media/Portrait", - "/media/Landscape", +# Paths to the folders that contain source images. Add as many as you like. +IMAGE_DIRS = [ + "/mnt/secret-items/sd-outputs/Sorted/Images/Portrait", + "/mnt/secret-items/sd-outputs/Sorted/Images/Landscape", ] -_IMAGE_DIRS_ENV = os.getenv("IMAGE_DIRS") -if _IMAGE_DIRS_ENV: - IMAGE_DIRS = _IMAGE_DIRS_ENV.split(":") -else: - IMAGE_DIRS = _IMAGE_DIRS_DEFAULT - # Backwards-compatibility: first directory -IMAGE_DIR = IMAGE_DIRS[0] if IMAGE_DIRS else "" +IMAGE_DIR = IMAGE_DIRS[0] + from typing import Optional -# Data directory (override with DATA_DIR env var) -DATA_DIR = os.getenv("DATA_DIR", BASE_DIR) - # SQLite database file that stores selections & metadata -DB_PATH = os.path.join(DATA_DIR, "image_selections.db") +DB_PATH = os.path.join(BASE_DIR, "data/image_selections.db") -# Default port for the HTTP server (override with PORT env var) -PORT = int(os.getenv("PORT", 8888)) +# Default port for the HTTP server +PORT = 8000 # --------------------------------------------------------------------------- # NSFW detection configuration diff --git a/config_custom.py b/config_custom.py new file mode 100644 index 0000000..64d0bfc --- /dev/null +++ b/config_custom.py @@ -0,0 +1,83 @@ +import os + +# Configuration constants for the SWIPER application +# -------------------------------------------------- +# Centralising these values avoids circular imports +# and makes it easy to update paths / ports later + +# Base directory of the repo (this file lives in the project root) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Paths to the folders that contain source images. Can be overridden at runtime +# via the IMAGE_DIRS environment variable (colon-separated paths). +_IMAGE_DIRS_DEFAULT = [ + "/media/Portrait", + "/media/Landscape", +] + +_IMAGE_DIRS_ENV = os.getenv("IMAGE_DIRS") +if _IMAGE_DIRS_ENV: + IMAGE_DIRS = _IMAGE_DIRS_ENV.split(":") +else: + IMAGE_DIRS = _IMAGE_DIRS_DEFAULT + +# Backwards-compatibility: first directory +IMAGE_DIR = IMAGE_DIRS[0] if IMAGE_DIRS else "" +from typing import Optional + +# Data directory (override with DATA_DIR env var) +DATA_DIR = os.getenv("DATA_DIR", BASE_DIR) + +# SQLite database file that stores selections & metadata +DB_PATH = os.path.join(DATA_DIR, "image_selections.db") + +# Default port for the HTTP server (override with PORT env var) +PORT = int(os.getenv("PORT", 8888)) + +# --------------------------------------------------------------------------- +# NSFW detection configuration +# --------------------------------------------------------------------------- +# List of keywords that, if present in an image's prompt data, should mark the +# image as NSFW. Feel free to customise this list as appropriate for your own +# needs. +NSFW_KEYWORDS = [ + "nude", + "nudity", + "porn", + "explicit", + "sexual", + "sex", + "boobs", + "nipples", + "penis", + "vagina", + "pussy", + "cum", + "fellatio", + "blowjob", + "cunnilingus", + "paizuri", + "rape", + "handjob", + "lingerie", + "bikini", + "latex", + "saliva", + "ass", + "condom", + ] + +# --------------------------------------------------------------------------- +# Utility helpers +# --------------------------------------------------------------------------- + +def find_image_file(rel_path: str) -> Optional[str]: + """Return absolute path to `rel_path` by searching all IMAGE_DIRS. + + Returns None if file is not found in any configured directory. + """ + for base in IMAGE_DIRS: + abs_path = os.path.join(base, rel_path) + if os.path.exists(abs_path): + return abs_path + return None diff --git a/docker-compose.yml b/docker-compose.yml index 4da4440..c71e79b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,4 +30,4 @@ services: - ./data:/data # Uncomment the line below if you maintain a custom config.py alongside # the compose file and want to override the image copy at runtime. - - ./config.py:/app/config.py:ro + - ./config_custom.py:/app/config.py:ro diff --git a/handler.py b/handler.py index 1557afa..bd1dbda 100644 --- a/handler.py +++ b/handler.py @@ -65,6 +65,8 @@ class ImageSwipeHandler(BaseHTTPRequestHandler): self.serve_random_image() elif path == "/selections": self.serve_selections() + elif path == "/image-count": + self.serve_image_count() elif path.startswith("/images/"): self.serve_image(path[8:]) elif path == "/favicon.ico": @@ -249,6 +251,68 @@ class ImageSwipeHandler(BaseHTTPRequestHandler): data = {"selections": get_selections()} self._json_response(data) + def serve_image_count(self) -> None: + """Return the total count of images available for the current filter.""" + parsed = urllib.parse.urlparse(self.path) + query_params = urllib.parse.parse_qs(parsed.query) + orientation_str = query_params.get("orientation", ["all"])[0] + orientations = [o.strip() for o in orientation_str.split(',')] + search_keywords_str = query_params.get("search", [""])[0].strip() + allow_nsfw = query_params.get("allow_nsfw", ["0"])[0] == "1" + search_keywords = [kw.strip() for kw in search_keywords_str.split(',') if kw.strip()] + actions_str = query_params.get("actions", ["Unactioned"])[0] + actions = [a.strip() for a in actions_str.split(',') if a.strip()] + + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + query = """ + SELECT COUNT(*) FROM image_metadata meta + LEFT JOIN prompt_details pd ON meta.path = pd.image_path + """ + params: List[str] = [] + where_clauses = ["(meta.actioned IS NULL OR meta.actioned != 'purged')"] + + # Action filter + action_conditions = [] + action_params = [] + if "Unactioned" in actions: + action_conditions.append("meta.actioned IS NULL") + actions.remove("Unactioned") + if actions: + placeholders = ", ".join("?" for _ in actions) + action_conditions.append(f"meta.actioned IN ({placeholders})") + action_params.extend(actions) + + if action_conditions: + where_clauses.append(f"({' OR '.join(action_conditions)})") + params.extend(action_params) + + # Orientation filter + if "all" not in orientations and orientations: + placeholders = ", ".join("?" for _ in orientations) + where_clauses.append(f"meta.orientation IN ({placeholders})") + params.extend(orientations) + + # NSFW filter + if not allow_nsfw: + where_clauses.append("meta.nsfw = 0") + + # Keyword filter + if search_keywords: + for keyword in search_keywords: + # Search only the positive prompt (pd.positive_prompt) + where_clauses.append("pd.positive_prompt LIKE ?") + params.append(f"%{keyword}%") + + if where_clauses: + query += " WHERE " + " AND ".join(where_clauses) + + cur.execute(query, params) + count = cur.fetchone()[0] + conn.close() + + self._json_response({"count": count}) + def serve_resolutions(self) -> None: # Collect resolutions across all configured directories resolutions_set = set() diff --git a/history.html b/history.html index 3f420a6..ab69324 100644 --- a/history.html +++ b/history.html @@ -165,6 +165,7 @@ - + + diff --git a/index.html b/index.html index 5a42aaa..4c214eb 100644 --- a/index.html +++ b/index.html @@ -114,6 +114,7 @@ Fullscreen - + + diff --git a/js/enhanced-history.js b/js/enhanced-history.js index b466f2b..a71b9d8 100644 --- a/js/enhanced-history.js +++ b/js/enhanced-history.js @@ -4,7 +4,7 @@ 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 resolutionFilter = document.getElementById('resolution-select'); const selectAllBtn = document.getElementById('select-all'); const deselectAllBtn = document.getElementById('deselect-all'); const downloadSelectedBtn = document.getElementById('download-selected'); @@ -176,7 +176,9 @@ document.addEventListener('DOMContentLoaded', function() {

No selections match the current filters

`; - filteredCountEl.textContent = `0 images match your filters (out of ${selections.length} total)`; + if (filteredCountEl) { + filteredCountEl.textContent = `0 images match your filters (out of ${selections.length} total)`; + } return; } @@ -259,8 +261,19 @@ document.addEventListener('DOMContentLoaded', function() { }; const updateDownloadButton = () => { + if (!downloadSelectedBtn) return; + downloadSelectedBtn.disabled = selectedItems.length === 0; - downloadSelectedBtn.querySelector('.label').textContent = selectedItems.length > 0 ? `Download (${selectedItems.length})` : 'Download'; + const labelEl = downloadSelectedBtn.querySelector('.label'); + if (labelEl) { + labelEl.textContent = selectedItems.length > 0 ? `Download (${selectedItems.length})` : 'Download'; + } else { + // If there's no label element, update the count element instead + const countEl = downloadSelectedBtn.querySelector('.count'); + if (countEl) { + countEl.textContent = selectedItems.length > 0 ? selectedItems.length : '0'; + } + } }; // Enhanced selection item click handler @@ -379,34 +392,53 @@ document.addEventListener('DOMContentLoaded', function() { confirmDialog.remove(); }, 400); - // Simulate delete functionality (replace with actual API call) - setTimeout(() => { - selectionItem.remove(); - showToast('Selection removed'); - - // Update selected items if this was selected - if (selectedItems.some(item => item.id === selectionId)) { - selectedItems = selectedItems.filter(item => item.id !== selectionId); - updateDownloadButton(); + // Delete the selection via API + fetch('/delete-selection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: selectionId }) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete selection'); } - - // Update allSelections array - allSelections = allSelections.filter(s => s.id !== selectionId); - - // Update stats - const stats = calculateStats(allSelections); - updateStats(stats); - - // Update filtered count - if (filteredCountEl) { - const filteredSelections = allSelections.filter(s => - (currentFilter === 'all' || s.action === currentFilter) && - (currentOrientation === 'all' || s.orientation === currentOrientation) && - (currentResolution === 'all' || s.resolution === currentResolution) - ); - filteredCountEl.textContent = `Showing ${filteredSelections.length} of ${allSelections.length} images`; + return response.json(); + }) + .then(data => { + if (data.success) { + selectionItem.remove(); + showToast('Selection removed'); + + // Update selected items if this was selected + if (selectedItems.some(item => item.id === selectionId)) { + selectedItems = selectedItems.filter(item => item.id !== selectionId); + updateDownloadButton(); + } + + // Update allSelections array + allSelections = allSelections.filter(s => s.id !== selectionId); + + // Update stats + const stats = calculateStats(allSelections); + updateStats(stats); + + // Update filtered count + if (filteredCountEl) { + const filteredSelections = allSelections.filter(s => + (currentFilter === 'all' || s.action === currentFilter) && + (currentOrientation === 'all' || s.orientation === currentOrientation) && + (currentResolution === 'all' || s.resolution === currentResolution) + ); + filteredCountEl.textContent = `Showing ${filteredSelections.length} of ${allSelections.length} images`; + } + } else { + showToast('Error removing selection', 'error'); } - }, 300); + }) + .catch(error => { + console.error('Error deleting selection:', error); + showToast('Error removing selection', 'error'); + }); }); confirmDialog.querySelector('.cancel-delete-btn').addEventListener('click', () => { @@ -523,44 +555,65 @@ document.addEventListener('DOMContentLoaded', function() { modalMessage.textContent = `Updating action to ${getActionName(action)}...`; modalMessage.style.color = '#3498db'; - // Simulate API call (replace with actual implementation) - setTimeout(() => { - modalMessage.textContent = `Action updated successfully!`; - modalMessage.style.color = '#2ecc71'; - - // Close modal after success - setTimeout(() => { - actionModal.classList.remove('show'); - setTimeout(() => { - actionModal.style.display = 'none'; - modalMessage.textContent = ''; - }, 400); + // Update the selection via API + fetch('/update-selection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: currentSelectionId, action }) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update selection'); + } + return response.json(); + }) + .then(data => { + if (data.success) { + modalMessage.textContent = `Action updated successfully!`; + modalMessage.style.color = '#2ecc71'; - // Update the UI to reflect the change - const selectionItem = document.querySelector(`.selection-item[data-id="${currentSelectionId}"]`); - if (selectionItem) { - const actionEl = selectionItem.querySelector('.selection-action'); - const oldAction = actionEl.classList[1].replace('action-', ''); + // Close modal after success + setTimeout(() => { + actionModal.classList.remove('show'); + setTimeout(() => { + actionModal.style.display = 'none'; + modalMessage.textContent = ''; + }, 400); - // Update the action element - actionEl.className = `selection-action action-${action}`; - actionEl.innerHTML = ` ${getActionName(action)}`; - - // Update the selection in allSelections - const selectionIndex = allSelections.findIndex(s => s.id === currentSelectionId); - if (selectionIndex !== -1) { - allSelections[selectionIndex].action = action; + // Update the UI to reflect the change + const selectionItem = document.querySelector(`.selection-item[data-id="${currentSelectionId}"]`); + if (selectionItem) { + const actionEl = selectionItem.querySelector('.selection-action'); + const oldAction = actionEl.classList[1].replace('action-', ''); + + // Update the action element + actionEl.className = `selection-action action-${action}`; + actionEl.innerHTML = ` ${getActionName(action)}`; + + // Update the selection in allSelections + const selectionIndex = allSelections.findIndex(s => s.id === currentSelectionId); + if (selectionIndex !== -1) { + allSelections[selectionIndex].action = action; + } + + // Update stats + const stats = calculateStats(allSelections); + updateStats(stats); + + // Show toast notification + showToast(`Updated to ${getActionName(action)}`); } - - // Update stats - const stats = calculateStats(allSelections); - updateStats(stats); - - // Show toast notification - showToast(`Updated to ${getActionName(action)}`); - } - }, 1000); - }, 800); + }, 1000); + } else { + modalMessage.textContent = `Error updating action`; + modalMessage.style.color = '#e74c3c'; + } + }) + .catch(error => { + console.error('Error updating selection:', error); + modalMessage.textContent = `Error: ${error.message}`; + modalMessage.style.color = '#e74c3c'; + }); }); }); @@ -579,40 +632,64 @@ document.addEventListener('DOMContentLoaded', function() { resetMessage.textContent = 'Deleting all selections...'; resetMessage.style.color = '#3498db'; - // Simulate API call (replace with actual implementation) - setTimeout(() => { - resetMessage.textContent = 'All selections have been deleted successfully!'; - resetMessage.style.color = '#2ecc71'; - - // Close modal after success - setTimeout(() => { - resetModal.classList.remove('show'); + // Reset database via API + fetch('/reset-database', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to reset database'); + } + return response.json(); + }) + .then(data => { + if (data.status === 'success') { + resetMessage.textContent = 'All selections have been deleted successfully!'; + resetMessage.style.color = '#2ecc71'; + + // Close modal after success setTimeout(() => { - resetModal.style.display = 'none'; - confirmResetBtn.disabled = false; - confirmResetBtn.textContent = 'Yes, Delete All'; - resetMessage.textContent = ''; - - // Clear the grid and update state - selectionGrid.innerHTML = ` -
- -

No selections found

-
- `; - selectedItems = []; - allSelections = []; - updateDownloadButton(); - - // Update stats - const stats = calculateStats([]); - updateStats(stats); - - // Show toast notification - showToast('All selections have been deleted'); - }, 400); - }, 1000); - }, 1500); + resetModal.classList.remove('show'); + setTimeout(() => { + resetModal.style.display = 'none'; + confirmResetBtn.disabled = false; + confirmResetBtn.textContent = 'Yes, Delete All'; + resetMessage.textContent = ''; + + // Clear the grid and update state + selectionGrid.innerHTML = ` +
+ +

No selections found

+
+ `; + selectedItems = []; + allSelections = []; + updateDownloadButton(); + + // Update stats + const stats = calculateStats([]); + updateStats(stats); + + // Show toast notification + showToast('All selections have been deleted'); + }, 400); + }, 1000); + } else { + resetMessage.textContent = 'Error resetting database'; + resetMessage.style.color = '#e74c3c'; + confirmResetBtn.disabled = false; + confirmResetBtn.textContent = 'Yes, Delete All'; + } + }) + .catch(error => { + console.error('Error resetting database:', error); + resetMessage.textContent = `Error: ${error.message}`; + resetMessage.style.color = '#e74c3c'; + confirmResetBtn.disabled = false; + confirmResetBtn.textContent = 'Yes, Delete All'; + }); }); cancelResetBtn.addEventListener('click', () => { diff --git a/js/enhanced-main.js b/js/enhanced-main.js index bdf2a63..657a463 100644 --- a/js/enhanced-main.js +++ b/js/enhanced-main.js @@ -69,7 +69,7 @@ document.addEventListener('DOMContentLoaded', () => { } function updateProgressBar() { - if (progressState.totalImages > 0) { + if (progressBar && progressState.totalImages > 0) { const percentage = (progressState.processedImages / progressState.totalImages) * 100; progressBar.style.width = `${Math.min(percentage, 100)}%`; } diff --git a/js/main.js b/js/main.js index 7b37e84..aad056a 100644 --- a/js/main.js +++ b/js/main.js @@ -16,6 +16,7 @@ document.addEventListener('DOMContentLoaded', () => { moveY: 0, touchStartTime: 0, hasMoved: false, + isAnimating: false, // Flag to prevent actions during animation }; const card = document.getElementById('current-card'); @@ -31,11 +32,30 @@ document.addEventListener('DOMContentLoaded', () => { const keywordPillsContainer = document.getElementById('keyword-pills-container'); const SWIPE_THRESHOLD = 100; + const ACTION_ANGLE_THRESHOLD = 15; // Degrees of rotation to commit to swipe + + function resetCardPosition(animated = true) { + if (state.isAnimating && animated) return; + + card.style.transition = animated ? 'transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1)' : 'none'; + card.style.transform = 'translate(0, 0) rotate(0deg)'; + + if (animated) { + state.isAnimating = true; + card.addEventListener('transitionend', () => { + state.isAnimating = false; + card.style.transition = 'none'; + }, { once: true }); + } else { + card.style.transition = 'none'; + } + } const performSwipe = (direction) => { - if (!state.currentImageInfo) return; + if (!state.currentImageInfo || state.isAnimating) return; - card.classList.add(`swipe-${direction}`); + state.isAnimating = true; + const actionNameMap = { left: 'Discarded', right: 'Kept', up: 'Favourited', down: 'Reviewing' }; const actionName = actionNameMap[direction] || direction; lastActionText.textContent = `Last action: ${actionName}`; @@ -44,310 +64,247 @@ document.addEventListener('DOMContentLoaded', () => { recordSelection(state.currentImageInfo, actionName); - setTimeout(() => { - card.classList.remove(`swipe-${direction}`); + // Animate swipe out + let rotation, translateX, translateY; + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + switch (direction) { + case 'left': + rotation = -30; + translateX = -windowWidth; + translateY = state.moveY; + break; + case 'right': + rotation = 30; + translateX = windowWidth; + translateY = state.moveY; + break; + case 'up': + rotation = 0; + translateX = state.moveX; + translateY = -windowHeight; + break; + case 'down': + rotation = 0; + translateX = state.moveX; + translateY = windowHeight; + break; + } + + card.style.transition = 'transform 0.5s cubic-bezier(0.6, -0.28, 0.735, 0.045)'; + card.style.transform = `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg)`; + + card.addEventListener('transitionend', () => { loadNewImage(); - }, 500); + state.isAnimating = false; + }, { once: true }); }; - const recordSelection = async (imageInfo, action) => { - try { - const response = await fetch('/selection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - image_path: imageInfo.path, - action, - }), - }); - if (!response.ok) { - console.error('Error recording selection. Status:', response.status); - } else { - const data = await response.json(); - console.log('Selection recorded:', data); - } - } catch (err) { - console.error('Error recording selection:', err); - } - }; - - const loadNewImage = () => { - if (state.isLoading) return; - state.isLoading = true; - card.classList.add('loading'); - - const params = new URLSearchParams({ + async function getNextImage() { + const queryParams = new URLSearchParams({ orientation: state.currentOrientation.join(','), - t: new Date().getTime(), - }); + actions: state.currentActions.join(','), + nsfw: state.allowNsfw, + keywords: state.searchKeywords.join(','), + }).toString(); - // NSFW param - params.append('allow_nsfw', state.allowNsfw ? '1' : '0'); - - if (state.searchKeywords.length > 0) { - params.append('search', state.searchKeywords.join(',')); + const response = await fetch(`/next-image?${queryParams}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } + return await response.json(); + } - if (state.currentActions.length > 0) { - params.append('actions', state.currentActions.join(',')); - } + function recordSelection(imageInfo, action) { + fetch('/record-selection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: imageInfo.filename, action: action }), + }).catch(error => console.error('Failed to record selection:', error)); + } - fetch(`/random-image?${params.toString()}`) - .then(response => response.json()) - .then(data => { - state.isLoading = false; - // card.classList.remove('loading'); // moved to image load handler - if (data && data.path) { - state.currentImageInfo = data; - const cardImage = card.querySelector('img'); - // Use load event to ensure indicator hides after image fully loads - cardImage.onload = () => { - card.classList.remove('loading'); - }; - cardImage.src = data.path; - updateImageInfo(data); - adjustContainerToImage(data.orientation); - } else { - const placeholder = 'static/no-image.png'; - const imgEl = card.querySelector('img'); - if (imgEl) { - imgEl.onload = () => card.classList.remove('loading'); - imgEl.src = placeholder; - } - updateImageInfo({ filename:'No image', creation_date:'', resolution:'', prompt_data:''}); - state.currentImageInfo = null; // disables swipe actions - } - }) - .catch(error => { - console.error('Error fetching image:', error); - state.isLoading = false; - card.classList.remove('loading'); - card.innerHTML = '
Error loading image.
'; - }); - }; + function handleNoImageAvailable() { + const imageElement = card.querySelector('img'); + imageElement.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%3ENo%20images%20found%3C%2Ftext%3E%3C%2Fsvg%3E`; + state.currentImageInfo = null; + updateImageInfo(null); + showToast('No more images matching your criteria.', 'info'); + } - 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'; + async function loadNewImage() { + if (state.isLoading) return; + resetCardPosition(false); // Reset position without animation for new image + state.isLoading = true; + const loadingIndicator = card.querySelector('.loading-indicator'); + loadingIndicator.style.display = 'block'; + + try { + const data = await getNextImage(); + if (data && data.image_data) { + const imageUrl = `data:image/jpeg;base64,${data.image_data}`; + const imageElement = card.querySelector('img'); + imageElement.src = imageUrl; + state.currentImageInfo = data; + updateImageInfo(data); } else { - container.style.flex = '2'; + handleNoImageAvailable(); } + } catch (error) { + console.error('Error loading image:', error); + showToast('Failed to load image.', 'error'); + handleNoImageAvailable(); + } finally { + state.isLoading = false; + loadingIndicator.style.display = 'none'; } - }; + } - const handlePointerDown = (x, y) => { + // --- Event Listeners --- + + // Swipe and Drag + card.addEventListener('mousedown', onDragStart); + card.addEventListener('touchstart', onDragStart, { passive: true }); + + function onDragStart(e) { + if (state.isLoading || state.isAnimating) return; state.isDragging = true; - state.startX = x; - state.startY = y; state.hasMoved = false; + card.style.transition = 'none'; + state.startX = e.clientX || e.touches[0].clientX; + state.startY = e.clientY || e.touches[0].clientY; state.touchStartTime = Date.now(); - card.classList.add('swiping'); - }; + document.addEventListener('mousemove', onDragMove); + document.addEventListener('touchmove', onDragMove, { passive: false }); + document.addEventListener('mouseup', onDragEnd); + document.addEventListener('touchend', onDragEnd); + } - const handlePointerMove = (x, y) => { + function onDragMove(e) { if (!state.isDragging) return; + e.preventDefault(); + state.hasMoved = true; + + const currentX = e.clientX || e.touches[0].clientX; + const currentY = e.clientY || e.touches[0].clientY; + + state.moveX = currentX - state.startX; + state.moveY = currentY - state.startY; - state.moveX = x - state.startX; - state.moveY = y - state.startY; + const rotation = state.moveX * 0.1; + card.style.transform = `translate(${state.moveX}px, ${state.moveY}px) rotate(${rotation}deg)`; + } - 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 = () => { + function onDragEnd() { if (!state.isDragging) return; state.isDragging = false; - card.classList.remove('swiping'); - const absX = Math.abs(state.moveX); - const absY = Math.abs(state.moveY); + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('touchmove', onDragMove); + document.removeEventListener('mouseup', onDragEnd); + document.removeEventListener('touchend', onDragEnd); - 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'); + if (!state.hasMoved) return; + + const rotation = state.moveX * 0.1; + + if (Math.abs(rotation) > ACTION_ANGLE_THRESHOLD || Math.abs(state.moveX) > SWIPE_THRESHOLD || Math.abs(state.moveY) > SWIPE_THRESHOLD) { + const angle = Math.atan2(state.moveY, state.moveX) * 180 / Math.PI; + let direction; + if (angle > -45 && angle <= 45) { + direction = 'right'; + } else if (angle > 45 && angle <= 135) { + direction = 'down'; + } else if (angle > 135 || angle <= -135) { + direction = 'left'; + } else if (angle > -135 && angle <= -45) { + direction = 'up'; } + performSwipe(direction); } else { - card.style.transform = ''; + resetCardPosition(); } - + 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')); - - document.addEventListener('keydown', (e) => { - if (state.isLoading || document.activeElement === searchInput) return; - - const keyMap = { - ArrowLeft: 'left', - ArrowRight: 'right', - ArrowUp: 'up', - ArrowDown: 'down', - }; - - if (keyMap[e.key]) { - e.preventDefault(); // Prevent scrolling - performSwipe(keyMap[e.key]); - } - }); + } + // Filter buttons orientationFilters.addEventListener('click', (e) => { const button = e.target.closest('button'); if (!button) return; - - const clickedOrientation = button.dataset.orientation; - - if (clickedOrientation === 'all') { - state.currentOrientation = ['all']; - } else { - // If 'all' was the only active filter, start a new selection - if (state.currentOrientation.length === 1 && state.currentOrientation[0] === 'all') { - state.currentOrientation = []; - } - - const index = state.currentOrientation.indexOf(clickedOrientation); - if (index > -1) { - // Already selected, so deselect - state.currentOrientation.splice(index, 1); - } else { - // Not selected, so select - state.currentOrientation.push(clickedOrientation); - } - } - - // If no filters are selected after interaction, default to 'all' - if (state.currentOrientation.length === 0) { - state.currentOrientation = ['all']; - } - - // Update UI based on the state - orientationFilters.querySelectorAll('button').forEach(btn => { - if (state.currentOrientation.includes(btn.dataset.orientation)) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - }); - + state.currentOrientation = [button.dataset.value]; loadNewImage(); }); actionFilters.addEventListener('click', (e) => { const button = e.target.closest('button'); if (!button) return; - - const clickedAction = button.dataset.action; - - if (state.currentActions.length === 1 && state.currentActions[0] === 'Unactioned' && clickedAction !== 'Unactioned') { - state.currentActions = []; - } - - const index = state.currentActions.indexOf(clickedAction); - if (index > -1) { - state.currentActions.splice(index, 1); - } else { - state.currentActions.push(clickedAction); - } - - if (state.currentActions.length === 0) { - state.currentActions = ['Unactioned']; - } - - actionFilters.querySelectorAll('button').forEach(btn => { - if (state.currentActions.includes(btn.dataset.action)) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - }); - + state.currentActions = [button.dataset.value]; loadNewImage(); }); + // Modal + card.addEventListener('click', (e) => { + if (!state.hasMoved && state.currentImageInfo) { + fullscreenImage.src = card.querySelector('img').src; + modal.style.display = 'flex'; + } + }); + + closeModal.addEventListener('click', () => { + modal.style.display = 'none'; + }); + + window.addEventListener('click', (e) => { + if (e.target === modal) { + modal.style.display = 'none'; + } + }); + + // Search + const addKeyword = () => { + const keyword = searchInput.value.trim(); + if (keyword && !state.searchKeywords.includes(keyword)) { + state.searchKeywords.push(keyword); + renderKeywordPills(); + searchInput.value = ''; + } + }; + + const removeKeyword = (keywordToRemove) => { + state.searchKeywords = state.searchKeywords.filter(k => k !== keywordToRemove); + renderKeywordPills(); + }; + const renderKeywordPills = () => { keywordPillsContainer.innerHTML = ''; state.searchKeywords.forEach(keyword => { const pill = document.createElement('div'); pill.className = 'keyword-pill'; pill.textContent = keyword; - - const removeBtn = document.createElement('button'); - removeBtn.className = 'remove-keyword'; - removeBtn.innerHTML = '×'; - removeBtn.dataset.keyword = keyword; + const removeBtn = document.createElement('span'); + removeBtn.textContent = 'x'; + removeBtn.onclick = () => removeKeyword(keyword); pill.appendChild(removeBtn); - keywordPillsContainer.appendChild(pill); }); }; - const addSearchKeyword = () => { - const newKeyword = searchInput.value.trim(); - if (newKeyword && !state.searchKeywords.includes(newKeyword)) { - state.searchKeywords.push(newKeyword); - renderKeywordPills(); - loadNewImage(); - } - searchInput.value = ''; - searchInput.focus(); - }; - - searchButton.addEventListener('click', addSearchKeyword); - - searchInput.addEventListener('keypress', (e) => { + searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { - addSearchKeyword(); + addKeyword(); } }); - keywordPillsContainer.addEventListener('click', (e) => { - if (e.target.classList.contains('remove-keyword')) { - const keywordToRemove = e.target.dataset.keyword; - state.searchKeywords = state.searchKeywords.filter(k => k !== keywordToRemove); - renderKeywordPills(); - 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'}`; - document.getElementById('modal-prompt-data').textContent = `Prompt: ${state.currentImageInfo.prompt_data || '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'; - } + searchButton.addEventListener('click', () => { + loadNewImage(); }); + // Keyboard shortcuts document.addEventListener('keydown', (e) => { + if (searchInput === document.activeElement) return; + if (modal.style.display === 'flex' && e.key === 'Escape') { modal.style.display = 'none'; return; diff --git a/js/ui-enhancements.js b/js/ui-enhancements.js index 29bdbe0..75c7333 100644 --- a/js/ui-enhancements.js +++ b/js/ui-enhancements.js @@ -7,6 +7,9 @@ function addSwipeDecisionIndicators() { const swipeContainer = document.querySelector('.swipe-container'); + // Only proceed if swipe container exists (not on history page) + if (!swipeContainer) return; + // Create decision indicators for each direction const directions = ['left', 'right', 'up', 'down']; const icons = ['fa-trash', 'fa-folder-plus', 'fa-star', 'fa-clock']; @@ -34,6 +37,9 @@ function enhanceLoadingIndicator() { function enhanceDirectionArrows() { const arrows = document.querySelectorAll('.direction-arrow'); + // Only proceed if direction arrows exist + if (arrows.length === 0) return; + arrows.forEach(arrow => { arrow.addEventListener('mouseenter', function() { this.style.transform = this.classList.contains('arrow-left') || this.classList.contains('arrow-right') @@ -54,13 +60,13 @@ function enhanceDirectionArrows() { // Show swipe decision indicator function showSwipeDecision(direction) { const indicator = document.querySelector(`.decision-${direction}`); - if (indicator) { - indicator.classList.add('visible'); - - setTimeout(() => { - indicator.classList.remove('visible'); - }, 800); - } + if (!indicator) return; + + indicator.classList.add('visible'); + + setTimeout(() => { + indicator.classList.remove('visible'); + }, 800); } // Enhance the performSwipe function @@ -68,6 +74,7 @@ function enhancePerformSwipe() { // Store the original performSwipe function const originalPerformSwipe = window.performSwipe; + // Only proceed if performSwipe exists (main page only) if (typeof originalPerformSwipe === 'function') { // Override with enhanced version window.performSwipe = function(direction) { diff --git a/js/utils.js b/js/utils.js index 05df4a5..0aafc56 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,11 +1,45 @@ -export function showToast(message) { +export function showToast(message, type = 'info') { const toastEl = document.getElementById('toast'); if (!toastEl) return; + + // Clear any existing classes + toastEl.className = 'toast'; + + // Add the appropriate class based on type + if (type === 'error') { + toastEl.classList.add('toast-error'); + } else if (type === 'success') { + toastEl.classList.add('toast-success'); + } else { + toastEl.classList.add('toast-info'); + } + toastEl.textContent = message; toastEl.classList.add('show'); setTimeout(() => toastEl.classList.remove('show'), 3000); } +export function addRippleEffect(button) { + if (!button) return; + + button.addEventListener('click', function(e) { + const ripple = document.createElement('span'); + ripple.classList.add('ripple'); + this.appendChild(ripple); + + const rect = button.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height); + + ripple.style.width = ripple.style.height = `${size}px`; + ripple.style.left = `${e.clientX - rect.left - size/2}px`; + ripple.style.top = `${e.clientY - rect.top - size/2}px`; + + setTimeout(() => { + ripple.remove(); + }, 600); + }); +} + export function updateImageInfo(data) { const resolutionEl = document.getElementById('image-resolution'); if (resolutionEl) { diff --git a/styles.css b/styles.css index 5dd4ac3..2c5b076 100644 --- a/styles.css +++ b/styles.css @@ -579,6 +579,36 @@ html, body { transform: translateX(-50%) translateY(-10px); } +/* Toast types */ +.toast-error { + background-color: rgba(231, 76, 60, 0.9); +} + +.toast-success { + background-color: rgba(46, 204, 113, 0.9); +} + +.toast-info { + background-color: rgba(52, 152, 219, 0.9); +} + +/* Ripple effect for buttons */ +.ripple { + position: absolute; + background: rgba(255, 255, 255, 0.3); + border-radius: 50%; + transform: scale(0); + animation: ripple 0.6s linear; + pointer-events: none; +} + +@keyframes ripple { + to { + transform: scale(4); + opacity: 0; + } +} + .modal { display: none; position: fixed;