// NextSnap - Photo Reviewer with Swipe Navigation and Rename 'use strict'; const Reviewer = { mode: null, photos: [], currentIndex: 0, username: null, currentPath: null, touchStartX: 0, touchStartY: 0, touchEndX: 0, touchEndY: 0, swipeThreshold: 50, preloadedImages: {}, isEditing: false, originalFilename: null, init(mode, username, photos, currentPath) { this.mode = mode; this.username = username; this.photos = photos; this.currentPath = currentPath || null; this.currentIndex = 0; if (this.photos.length === 0) { this.showEmptyState(); return; } this.setupEventListeners(); this.displayPhoto(0); this.preloadAdjacentPhotos(0); this.updatePosition(); }, setupEventListeners() { const viewer = document.getElementById('photo-viewer'); viewer.addEventListener('touchstart', (e) => { this.touchStartX = e.changedTouches[0].screenX; this.touchStartY = e.changedTouches[0].screenY; }, { passive: true }); viewer.addEventListener('touchend', (e) => { this.touchEndX = e.changedTouches[0].screenX; this.touchEndY = e.changedTouches[0].screenY; this.handleSwipe(); }, { passive: true }); document.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') this.previousPhoto(); else if (e.key === 'ArrowRight') this.nextPhoto(); else if (e.key === 'Escape') this.exit(); }); document.getElementById('prev-btn').addEventListener('click', () => this.previousPhoto()); document.getElementById('next-btn').addEventListener('click', () => this.nextPhoto()); document.getElementById('done-btn').addEventListener('click', () => this.exit()); const filenameInput = document.getElementById('filename-input'); filenameInput.addEventListener('focus', () => { this.isEditing = true; this.originalFilename = filenameInput.value; }); filenameInput.addEventListener('blur', () => { this.saveFilename(); }); filenameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); filenameInput.blur(); } else if (e.key === 'Escape') { filenameInput.value = this.originalFilename; filenameInput.blur(); this.isEditing = false; } }); viewer.addEventListener('click', (e) => { if (this.isEditing && !filenameInput.contains(e.target)) { filenameInput.blur(); } }); }, handleSwipe() { const deltaX = this.touchEndX - this.touchStartX; const deltaY = this.touchEndY - this.touchStartY; if (Math.abs(deltaY) > Math.abs(deltaX)) return; if (Math.abs(deltaX) < this.swipeThreshold) return; if (deltaX > 0) this.previousPhoto(); else this.nextPhoto(); }, async displayPhoto(index) { if (index < 0 || index >= this.photos.length) return; if (this.isEditing) await this.saveFilename(); this.currentIndex = index; const photo = this.photos[index]; const img = document.getElementById('current-photo'); const spinner = document.getElementById('photo-spinner'); spinner.style.display = 'block'; img.style.display = 'none'; if (this.mode === 'local') { img.src = URL.createObjectURL(photo.blob); } else { img.src = this.preloadedImages[index] || await this.fetchThumbnail(photo); } img.onload = () => { spinner.style.display = 'none'; img.style.display = 'block'; }; const filenameInput = document.getElementById('filename-input'); filenameInput.value = photo.filename.replace(/\.jpg$/i, ''); this.updatePosition(); this.updateNavigationButtons(); this.preloadAdjacentPhotos(index); }, async fetchThumbnail(photo) { const path = this.currentPath + '/' + photo.filename; const url = '/api/files/thumbnail?path=' + encodeURIComponent(path) + '&size=512'; try { const response = await fetch(url); if (!response.ok) throw new Error('Failed to fetch thumbnail'); const blob = await response.blob(); return URL.createObjectURL(blob); } catch (error) { console.error('Error fetching thumbnail:', error); return '/static/icons/icon-192.png'; } }, preloadAdjacentPhotos(index) { const toPreload = [index - 1, index + 1]; if (this.mode === 'remote') toPreload.push(index - 2, index + 2); toPreload.forEach(async (i) => { if (i >= 0 && i < this.photos.length && !this.preloadedImages[i]) { const photo = this.photos[i]; if (this.mode === 'local') { this.preloadedImages[i] = URL.createObjectURL(photo.blob); } else { this.preloadedImages[i] = await this.fetchThumbnail(photo); } } }); }, updatePosition() { const position = document.getElementById('photo-position'); position.textContent = (this.currentIndex + 1) + ' / ' + this.photos.length; }, updateNavigationButtons() { document.getElementById('prev-btn').disabled = this.currentIndex === 0; document.getElementById('next-btn').disabled = this.currentIndex === this.photos.length - 1; }, previousPhoto() { if (this.currentIndex > 0) this.displayPhoto(this.currentIndex - 1); }, nextPhoto() { if (this.currentIndex < this.photos.length - 1) this.displayPhoto(this.currentIndex + 1); }, async saveFilename() { if (!this.isEditing) return; const filenameInput = document.getElementById('filename-input'); const newBasename = filenameInput.value.trim(); const photo = this.photos[this.currentIndex]; const currentBasename = photo.filename.replace(/\.jpg$/i, ''); if (newBasename === currentBasename) { this.isEditing = false; return; } const validation = this.validateFilename(newBasename); if (!validation.valid) { this.showError(validation.error); filenameInput.value = currentBasename; this.isEditing = false; return; } const newFilename = newBasename + '.jpg'; const saveSpinner = document.getElementById('save-spinner'); saveSpinner.style.display = 'inline-block'; filenameInput.disabled = true; try { if (this.mode === 'local') { await Storage.updatePhoto(photo.id, { filename: newFilename }); photo.filename = newFilename; console.log('Updated local filename to: ' + newFilename); this.showSuccess('Filename updated'); } else { if (!navigator.onLine) throw new Error('Offline - cannot rename remote files'); const oldPath = this.currentPath + '/' + photo.filename; const newPath = this.currentPath + '/' + newFilename; const response = await fetch('/api/files/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourcePath: oldPath, destPath: newPath }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Rename failed'); } photo.filename = newFilename; console.log('Renamed remote file to: ' + newFilename); this.showSuccess('File renamed on server'); } } catch (error) { console.error('Error saving filename:', error); this.showError(error.message); filenameInput.value = currentBasename; } finally { saveSpinner.style.display = 'none'; filenameInput.disabled = false; this.isEditing = false; } }, validateFilename(filename) { if (!filename || filename.length === 0) { return { valid: false, error: 'Filename cannot be empty' }; } const invalidChars = /[\/\\?*"|<>:]/; if (invalidChars.test(filename)) { return { valid: false, error: 'Invalid characters in filename' }; } if (filename.length > 200) { return { valid: false, error: 'Filename too long (max 200 characters)' }; } return { valid: true }; }, showError(message) { const toast = document.getElementById('toast'); toast.textContent = '❌ ' + message; toast.className = 'toast error'; toast.style.display = 'block'; setTimeout(() => { toast.style.display = 'none'; }, 3000); }, showSuccess(message) { const toast = document.getElementById('toast'); toast.textContent = '✓ ' + message; toast.className = 'toast success'; toast.style.display = 'block'; setTimeout(() => { toast.style.display = 'none'; }, 2000); }, showEmptyState() { const viewer = document.getElementById('photo-viewer'); viewer.innerHTML = '
No photos to review