Offline-first photo capture app for Nextcloud with: - Camera capture with continuous mode (auto-reopens after each photo) - File browser with fullscreen image gallery, swipe navigation, and rename - Upload queue with background sync engine - Admin panel for Nextcloud user management - Service worker for offline-first caching (v13) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
// 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 = '<div class="empty-state"><p>No photos to review</p><button class="btn btn-primary" onclick="history.back()">Go Back</button></div>';
|
|
},
|
|
|
|
exit() {
|
|
if (this.isEditing) this.saveFilename();
|
|
Object.values(this.preloadedImages).forEach(url => URL.revokeObjectURL(url));
|
|
history.back();
|
|
}
|
|
};
|
|
|
|
window.Reviewer = Reviewer;
|