Add NextSnap PWA with photo gallery viewer and continuous capture

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>
This commit is contained in:
2026-02-07 04:53:13 -06:00
commit cad4118f72
55 changed files with 9038 additions and 0 deletions

288
app/static/js/reviewer.js Normal file
View File

@@ -0,0 +1,288 @@
// 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;