362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
import { showToast, updateImageInfo } from './utils.js';
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const state = {
|
|
currentImageInfo: null,
|
|
currentOrientation: ['all'],
|
|
currentActions: ['Unactioned'],
|
|
previousOrientation: ['all'],
|
|
allowNsfw: false,
|
|
searchKeywords: [],
|
|
isLoading: false,
|
|
isDragging: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
moveX: 0,
|
|
moveY: 0,
|
|
touchStartTime: 0,
|
|
hasMoved: false,
|
|
isAnimating: false, // Flag to prevent actions during animation
|
|
};
|
|
|
|
const card = document.getElementById('current-card');
|
|
const lastActionText = document.getElementById('last-action');
|
|
const orientationFilters = document.querySelector('.orientation-filters');
|
|
const actionFilters = document.querySelector('.action-filters');
|
|
const modal = document.getElementById('fullscreen-modal');
|
|
const fullscreenImage = document.getElementById('fullscreen-image');
|
|
const closeModal = document.querySelector('.close-modal');
|
|
const searchInput = document.getElementById('search-input');
|
|
const nsfwToggleBtn = document.getElementById('toggle-nsfw');
|
|
const searchButton = document.getElementById('search-button');
|
|
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 || state.isAnimating) return;
|
|
|
|
state.isAnimating = true;
|
|
|
|
const actionNameMap = { left: 'Discarded', right: 'Kept', up: 'Favourited', down: 'Reviewing' };
|
|
const actionName = actionNameMap[direction] || direction;
|
|
lastActionText.textContent = `Last action: ${actionName}`;
|
|
const toastMap = { left: 'Discarded', right: 'Kept', up: 'Favourited', down: 'Reviewing' };
|
|
showToast(toastMap[direction] || 'Action');
|
|
|
|
recordSelection(state.currentImageInfo, actionName);
|
|
|
|
// 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();
|
|
state.isAnimating = false;
|
|
}, { once: true });
|
|
};
|
|
|
|
async function getNextImage() {
|
|
const queryParams = new URLSearchParams({
|
|
orientation: state.currentOrientation.join(','),
|
|
actions: state.currentActions.join(','),
|
|
nsfw: state.allowNsfw,
|
|
keywords: state.searchKeywords.join(','),
|
|
}).toString();
|
|
|
|
const response = await fetch(`/next-image?${queryParams}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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 {
|
|
handleNoImageAvailable();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading image:', error);
|
|
showToast('Failed to load image.', 'error');
|
|
handleNoImageAvailable();
|
|
} finally {
|
|
state.isLoading = false;
|
|
loadingIndicator.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// --- 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.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();
|
|
document.addEventListener('mousemove', onDragMove);
|
|
document.addEventListener('touchmove', onDragMove, { passive: false });
|
|
document.addEventListener('mouseup', onDragEnd);
|
|
document.addEventListener('touchend', onDragEnd);
|
|
}
|
|
|
|
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;
|
|
|
|
const rotation = state.moveX * 0.1;
|
|
card.style.transform = `translate(${state.moveX}px, ${state.moveY}px) rotate(${rotation}deg)`;
|
|
}
|
|
|
|
function onDragEnd() {
|
|
if (!state.isDragging) return;
|
|
state.isDragging = false;
|
|
|
|
document.removeEventListener('mousemove', onDragMove);
|
|
document.removeEventListener('touchmove', onDragMove);
|
|
document.removeEventListener('mouseup', onDragEnd);
|
|
document.removeEventListener('touchend', onDragEnd);
|
|
|
|
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 {
|
|
resetCardPosition();
|
|
}
|
|
|
|
state.moveX = 0;
|
|
state.moveY = 0;
|
|
}
|
|
|
|
// Filter buttons
|
|
orientationFilters.addEventListener('click', (e) => {
|
|
const button = e.target.closest('button');
|
|
if (!button) return;
|
|
state.currentOrientation = [button.dataset.value];
|
|
loadNewImage();
|
|
});
|
|
|
|
actionFilters.addEventListener('click', (e) => {
|
|
const button = e.target.closest('button');
|
|
if (!button) return;
|
|
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('span');
|
|
removeBtn.textContent = 'x';
|
|
removeBtn.onclick = () => removeKeyword(keyword);
|
|
pill.appendChild(removeBtn);
|
|
keywordPillsContainer.appendChild(pill);
|
|
});
|
|
};
|
|
|
|
searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
addKeyword();
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Ultra-wide mode ---
|
|
const fullscreenToggle = document.getElementById('fullscreen-toggle');
|
|
fullscreenToggle.setAttribute('title', 'Toggle fullscreen Mode');
|
|
|
|
const setfullscreenMode = (isActive) => {
|
|
if (isActive) {
|
|
// Entering ultra-wide mode: just disable filter controls
|
|
orientationFilters.style.pointerEvents = 'none';
|
|
orientationFilters.style.opacity = '0.5';
|
|
} else {
|
|
// Exiting ultra-wide mode: re-enable filter controls
|
|
orientationFilters.style.pointerEvents = 'auto';
|
|
orientationFilters.style.opacity = '1';
|
|
}
|
|
};
|
|
|
|
fullscreenToggle.addEventListener('click', () => {
|
|
const isActive = document.body.classList.toggle('fullscreen-mode');
|
|
localStorage.setItem('fullscreenMode', isActive);
|
|
showToast(isActive ? 'fullscreen mode enabled' : 'fullscreen mode disabled');
|
|
setfullscreenMode(isActive);
|
|
});
|
|
|
|
// Check for saved preference on load
|
|
const isfullscreenModeOnLoad = localStorage.getItem('fullscreenMode') === 'true';
|
|
if (isfullscreenModeOnLoad) {
|
|
document.body.classList.add('fullscreen-mode');
|
|
setfullscreenMode(true);
|
|
}
|
|
|
|
// --- NSFW toggle ---
|
|
nsfwToggleBtn.addEventListener('click', () => {
|
|
state.allowNsfw = !state.allowNsfw;
|
|
nsfwToggleBtn.dataset.allow = state.allowNsfw ? '1' : '0';
|
|
nsfwToggleBtn.classList.toggle('active', state.allowNsfw);
|
|
loadNewImage();
|
|
});
|
|
|
|
loadNewImage(); // Always load an image on startup
|
|
});
|