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

246
app/static/js/admin.js Normal file
View File

@@ -0,0 +1,246 @@
// NextSnap - Admin Panel Logic
'use strict';
const Admin = {
users: [],
async init() {
await this.loadUsers();
this.setupEventListeners();
},
setupEventListeners() {
document.getElementById('add-user-form').addEventListener('submit', (e) => {
e.preventDefault();
this.createUser();
});
document.getElementById('refresh-btn').addEventListener('click', () => {
this.loadUsers();
});
},
async loadUsers() {
const userList = document.getElementById('user-list');
const loadingMsg = document.getElementById('loading-msg');
const errorMsg = document.getElementById('error-msg');
loadingMsg.style.display = 'block';
errorMsg.style.display = 'none';
userList.innerHTML = '';
try {
const response = await fetch('/api/admin/users');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to load users');
}
const data = await response.json();
this.users = data.users || [];
loadingMsg.style.display = 'none';
if (this.users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="empty-state">No users found</td></tr>';
return;
}
this.users.forEach(user => {
const row = this.createUserRow(user);
userList.appendChild(row);
});
} catch (error) {
console.error('Error loading users:', error);
loadingMsg.style.display = 'none';
errorMsg.textContent = error.message;
errorMsg.style.display = 'block';
}
},
createUserRow(user) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${this.escapeHtml(user.id)}</td>
<td>${this.escapeHtml(user.displayname || '-')}</td>
<td>${this.escapeHtml(user.email || '-')}</td>
<td>
<span class="badge ${user.enabled ? 'badge-success' : 'badge-danger'}">
${user.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td>
<div class="action-buttons">
${user.enabled ?
`<button class="btn-action btn-warning" onclick="Admin.disableUser('${user.id}')">Disable</button>` :
`<button class="btn-action btn-success" onclick="Admin.enableUser('${user.id}')">Enable</button>`
}
<button class="btn-action btn-danger" onclick="Admin.confirmDeleteUser('${user.id}')">Delete</button>
</div>
</td>
`;
return row;
},
async createUser() {
const form = document.getElementById('add-user-form');
const submitBtn = document.getElementById('submit-btn');
const formError = document.getElementById('form-error');
const formSuccess = document.getElementById('form-success');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const email = document.getElementById('new-email').value.trim();
const displayName = document.getElementById('new-displayname').value.trim();
formError.style.display = 'none';
formSuccess.style.display = 'none';
if (!username || !password) {
formError.textContent = 'Username and password are required';
formError.style.display = 'block';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
try {
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
password: password,
email: email || null,
displayName: displayName || null
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to create user');
}
formSuccess.textContent = `User "${username}" created successfully!`;
formSuccess.style.display = 'block';
form.reset();
setTimeout(() => {
this.loadUsers();
}, 1000);
} catch (error) {
console.error('Error creating user:', error);
formError.textContent = error.message;
formError.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create User';
}
},
async enableUser(username) {
if (!confirm(`Enable user "${username}"?`)) return;
try {
const response = await fetch(`/api/admin/users/${username}/enable`, {
method: 'PUT'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to enable user');
}
this.showToast(`User "${username}" enabled`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error enabling user:', error);
this.showToast(error.message, 'error');
}
},
async disableUser(username) {
if (!confirm(`Disable user "${username}"?`)) return;
try {
const response = await fetch(`/api/admin/users/${username}/disable`, {
method: 'PUT'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to disable user');
}
this.showToast(`User "${username}" disabled`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error disabling user:', error);
this.showToast(error.message, 'error');
}
},
confirmDeleteUser(username) {
const modal = document.getElementById('delete-modal');
const confirmBtn = document.getElementById('confirm-delete');
document.getElementById('delete-username').textContent = username;
modal.style.display = 'flex';
confirmBtn.onclick = () => {
this.deleteUser(username);
this.hideDeleteModal();
};
},
hideDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
},
async deleteUser(username) {
try {
const response = await fetch(`/api/admin/users/${username}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete user');
}
this.showToast(`User "${username}" deleted`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error deleting user:', error);
this.showToast(error.message, 'error');
}
},
showToast(message, type) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type}`;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
window.Admin = Admin;

111
app/static/js/app.js Normal file
View File

@@ -0,0 +1,111 @@
// NextSnap - Main application logic
'use strict';
const NextSnap = {
version: '1.0.0',
isOnline: navigator.onLine,
init() {
console.log(`NextSnap v${this.version} initializing...`);
this.setupConnectivityMonitoring();
this.setupServiceWorkerMessaging();
this.checkHealth();
},
setupConnectivityMonitoring() {
// Update online status on events
window.addEventListener('online', () => {
console.log('Network: Online');
this.isOnline = true;
this.updateConnectivityIndicator();
// Sync is handled by SyncEngine's own online listener in sync.js
});
window.addEventListener('offline', () => {
console.log('Network: Offline');
this.isOnline = false;
this.updateConnectivityIndicator();
});
// Initial status
this.updateConnectivityIndicator();
},
updateConnectivityIndicator() {
const indicator = document.querySelector('.connectivity-indicator');
if (!indicator) return;
indicator.classList.remove('online', 'offline', 'syncing');
if (this.isOnline) {
indicator.classList.add('online');
indicator.title = 'Online';
} else {
indicator.classList.add('offline');
indicator.title = 'Offline';
}
},
setSyncingStatus(isSyncing) {
const indicator = document.querySelector('.connectivity-indicator');
if (!indicator) return;
if (isSyncing) {
indicator.classList.add('syncing');
indicator.title = 'Syncing...';
} else {
indicator.classList.remove('syncing');
indicator.title = this.isOnline ? 'Online' : 'Offline';
}
},
setupServiceWorkerMessaging() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
// Listen for messages from service worker
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('Message from SW:', event.data);
});
}
},
// Force service worker update
async updateServiceWorker() {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
console.log('Service worker update check triggered');
}
}
},
// Clear all caches (for debugging)
async clearCache() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_CACHE'
});
console.log('Cache clear requested');
}
},
async checkHealth() {
try {
const response = await fetch('/api/health');
const data = await response.json();
console.log('Health check:', data);
} catch (error) {
console.error('Health check failed:', error);
}
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => NextSnap.init());
} else {
NextSnap.init();
}
// Make NextSnap globally available
window.NextSnap = NextSnap;

160
app/static/js/auth.js Normal file
View File

@@ -0,0 +1,160 @@
// NextSnap - Authentication logic
'use strict';
const Auth = {
init() {
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', this.handleLogin.bind(this));
// Add input validation
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
if (usernameInput) {
usernameInput.addEventListener('blur', () => this.validateUsername());
usernameInput.addEventListener('input', () => this.clearFieldError('username'));
}
if (passwordInput) {
passwordInput.addEventListener('input', () => this.clearFieldError('password'));
}
}
},
validateUsername() {
const username = document.getElementById('username').value.trim();
const usernameError = document.getElementById('username-error');
const usernameInput = document.getElementById('username');
if (!username) {
usernameError.textContent = 'Username is required';
usernameInput.classList.add('error');
return false;
}
if (username.length < 2) {
usernameError.textContent = 'Username must be at least 2 characters';
usernameInput.classList.add('error');
return false;
}
usernameError.textContent = '';
usernameInput.classList.remove('error');
return true;
},
validatePassword() {
const password = document.getElementById('password').value;
const passwordError = document.getElementById('password-error');
const passwordInput = document.getElementById('password');
if (!password) {
passwordError.textContent = 'Password is required';
passwordInput.classList.add('error');
return false;
}
passwordError.textContent = '';
passwordInput.classList.remove('error');
return true;
},
clearFieldError(field) {
const errorElement = document.getElementById(`${field}-error`);
const inputElement = document.getElementById(field);
if (errorElement) errorElement.textContent = '';
if (inputElement) inputElement.classList.remove('error');
},
async handleLogin(event) {
event.preventDefault();
// Validate fields
const usernameValid = this.validateUsername();
const passwordValid = this.validatePassword();
if (!usernameValid || !passwordValid) {
return;
}
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
const loginBtn = document.getElementById('login-btn');
const loginBtnText = document.getElementById('login-btn-text');
const loginBtnLoading = document.getElementById('login-btn-loading');
// Clear previous error
errorMessage.classList.add('hidden');
errorMessage.textContent = '';
// Disable button and show loading state
loginBtn.disabled = true;
loginBtnText.classList.add('hidden');
loginBtnLoading.classList.remove('hidden');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok && data.success) {
// Login successful - redirect to capture page
window.location.href = '/capture';
} else {
// Login failed - show error
const errorText = data.error || 'Login failed. Please check your credentials and try again.';
errorMessage.textContent = errorText;
errorMessage.classList.remove('hidden');
// Focus back on username for retry
document.getElementById('username').focus();
}
} catch (error) {
console.error('Login error:', error);
errorMessage.textContent = 'Network error. Please check your connection and try again.';
errorMessage.classList.remove('hidden');
} finally {
// Re-enable button and restore normal state
loginBtn.disabled = false;
loginBtnText.classList.remove('hidden');
loginBtnLoading.classList.add('hidden');
}
},
async checkStatus() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
return data.authenticated ? data : null;
} catch (error) {
console.error('Auth status check failed:', error);
return null;
}
},
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
console.error('Logout error:', error);
// Redirect anyway
window.location.href = '/';
}
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => Auth.init());
} else {
Auth.init();
}

318
app/static/js/camera.js Normal file
View File

@@ -0,0 +1,318 @@
// NextSnap - Camera Capture and JPEG Conversion
'use strict';
const Camera = {
input: null,
currentUsername: null,
init(username) {
this.currentUsername = username;
this.input = document.getElementById('camera-input');
const captureBtn = document.getElementById('capture-btn');
if (this.input && captureBtn) {
captureBtn.addEventListener('click', () => this.triggerCapture());
this.input.addEventListener('change', (e) => this.handleCapture(e));
}
},
triggerCapture() {
if (this.input) {
this.input.click();
}
},
async handleCapture(event) {
const file = event.target.files[0];
if (!file) return;
try {
// Show loading state
this.showCaptureLoading();
// Convert to JPEG
const jpegBlob = await this.convertToJPEG(file);
// Generate filename
const filename = this.generateFilename();
// Get target path
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
// Save to IndexedDB
const photoId = await Storage.savePhoto({
username: this.currentUsername,
timestamp: Date.now(),
filename: filename,
targetPath: targetPath,
blob: jpegBlob,
status: 'pending'
});
// Show success feedback
this.showCaptureSuccess(jpegBlob);
// Update recent photos display
this.updateRecentPhotos();
// Update pending count
this.updatePendingCount();
// Clear input for next capture
event.target.value = '';
// Trigger sync if online (will be implemented in Phase 7)
if (navigator.onLine && typeof Sync !== 'undefined') {
Sync.triggerSync();
}
} catch (error) {
console.error('Error capturing photo:', error);
this.showCaptureError(error.message);
}
},
async convertToJPEG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const img = new Image();
img.onload = async () => {
try {
// Get EXIF orientation
const orientation = await this.getOrientation(file);
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Set canvas dimensions based on orientation
let width = img.width;
let height = img.height;
if (orientation >= 5 && orientation <= 8) {
// Swap dimensions for rotated images
canvas.width = height;
canvas.height = width;
} else {
canvas.width = width;
canvas.height = height;
}
// Apply orientation transformation
this.applyOrientation(ctx, orientation, width, height);
// Draw image
ctx.drawImage(img, 0, 0, width, height);
// Convert to JPEG blob
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert image to JPEG'));
}
},
'image/jpeg',
0.92
);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = e.target.result;
} catch (error) {
reject(error);
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsDataURL(file);
});
},
async getOrientation(file) {
// Simple EXIF orientation detection
// For production, consider using a library like exif-js
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const view = new DataView(e.target.result);
if (view.getUint16(0, false) !== 0xFFD8) {
resolve(1); // Not a JPEG
return;
}
const length = view.byteLength;
let offset = 2;
while (offset < length) {
if (view.getUint16(offset + 2, false) <= 8) {
resolve(1);
return;
}
const marker = view.getUint16(offset, false);
offset += 2;
if (marker === 0xFFE1) {
// EXIF marker
const little = view.getUint16(offset + 8, false) === 0x4949;
offset += 10;
const tags = view.getUint16(offset, little);
offset += 2;
for (let i = 0; i < tags; i++) {
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
resolve(view.getUint16(offset + (i * 12) + 8, little));
return;
}
}
} else if ((marker & 0xFF00) !== 0xFF00) {
break;
} else {
offset += view.getUint16(offset, false);
}
}
resolve(1); // Default orientation
};
reader.onerror = () => resolve(1);
reader.readAsArrayBuffer(file.slice(0, 64 * 1024));
});
},
applyOrientation(ctx, orientation, width, height) {
switch (orientation) {
case 2:
// Horizontal flip
ctx.transform(-1, 0, 0, 1, width, 0);
break;
case 3:
// 180° rotate
ctx.transform(-1, 0, 0, -1, width, height);
break;
case 4:
// Vertical flip
ctx.transform(1, 0, 0, -1, 0, height);
break;
case 5:
// Vertical flip + 90° rotate
ctx.transform(0, 1, 1, 0, 0, 0);
break;
case 6:
// 90° rotate
ctx.transform(0, 1, -1, 0, height, 0);
break;
case 7:
// Horizontal flip + 90° rotate
ctx.transform(0, -1, -1, 0, height, width);
break;
case 8:
// 270° rotate
ctx.transform(0, -1, 1, 0, 0, width);
break;
default:
// No transformation
break;
}
},
generateFilename() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${this.currentUsername}_${year}${month}${day}_${hours}${minutes}${seconds}.jpg`;
},
showCaptureLoading() {
const btn = document.getElementById('capture-btn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Processing...';
}
},
showCaptureSuccess(blob) {
const btn = document.getElementById('capture-btn');
if (btn) {
btn.textContent = '✓ Photo Saved!';
setTimeout(() => {
btn.disabled = false;
btn.textContent = '📷 Take Photo';
}, 1500);
}
},
showCaptureError(message) {
const btn = document.getElementById('capture-btn');
if (btn) {
btn.textContent = '❌ Error';
setTimeout(() => {
btn.disabled = false;
btn.textContent = '📷 Take Photo';
}, 2000);
}
alert('Failed to capture photo: ' + message);
},
async updateRecentPhotos() {
if (!this.currentUsername) return;
const thumbnailsContainer = document.getElementById('photo-thumbnails');
if (!thumbnailsContainer) return;
const recentPhotos = await Storage.getRecentPhotos(this.currentUsername, 5);
if (recentPhotos.length === 0) {
thumbnailsContainer.innerHTML = '<p class="empty-state">No photos captured yet</p>';
return;
}
let html = '';
for (const photo of recentPhotos) {
const url = URL.createObjectURL(photo.blob);
const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'pending' ? 'pending' :
photo.status === 'uploading' ? 'uploading' : '';
html += `
<div class="thumbnail">
<img src="${url}" alt="${photo.filename}">
<span class="status-badge ${statusClass}"></span>
</div>
`;
}
thumbnailsContainer.innerHTML = html;
},
async updatePendingCount() {
if (!this.currentUsername) return;
const countElement = document.getElementById('pending-count');
if (!countElement) return;
const count = await Storage.getPhotoCount(this.currentUsername, 'pending');
countElement.textContent = `${count} pending`;
}
};

View File

@@ -0,0 +1,530 @@
// NextSnap - File Browser Logic with Photo Gallery
'use strict';
const FileBrowser = {
currentPath: '/',
// Gallery state
galleryOpen: false,
galleryImages: [],
galleryIndex: 0,
galleryTouchStartX: 0,
galleryTouchStartY: 0,
galleryTouchEndX: 0,
galleryTouchEndY: 0,
galleryPreloaded: {},
init() {
this.loadCurrentPath();
this.loadDirectory(this.currentPath);
this.setupEventListeners();
},
setupEventListeners() {
const newFolderBtn = document.getElementById('new-folder-btn');
if (newFolderBtn) {
newFolderBtn.addEventListener('click', () => this.createNewFolder());
}
// Gallery controls
document.getElementById('gallery-close-btn').addEventListener('click', () => this.closeGallery());
document.getElementById('gallery-prev-btn').addEventListener('click', () => this.galleryPrev());
document.getElementById('gallery-next-btn').addEventListener('click', () => this.galleryNext());
document.getElementById('gallery-rename-btn').addEventListener('click', () => this.openRenameModal());
// Rename modal
document.getElementById('gallery-rename-cancel').addEventListener('click', () => this.closeRenameModal());
document.getElementById('gallery-rename-save').addEventListener('click', () => this.saveRename());
document.getElementById('gallery-rename-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.saveRename();
} else if (e.key === 'Escape') {
this.closeRenameModal();
}
});
document.getElementById('gallery-rename-input').addEventListener('input', () => {
document.getElementById('gallery-rename-error').textContent = '';
});
// Touch events for swipe on gallery image container
const container = document.getElementById('gallery-image-container');
container.addEventListener('touchstart', (e) => {
this.galleryTouchStartX = e.changedTouches[0].screenX;
this.galleryTouchStartY = e.changedTouches[0].screenY;
}, { passive: true });
container.addEventListener('touchend', (e) => {
this.galleryTouchEndX = e.changedTouches[0].screenX;
this.galleryTouchEndY = e.changedTouches[0].screenY;
this.handleGallerySwipe();
}, { passive: true });
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (!this.galleryOpen) return;
if (e.key === 'ArrowLeft') this.galleryPrev();
else if (e.key === 'ArrowRight') this.galleryNext();
else if (e.key === 'Escape') this.closeGallery();
});
},
isImageFile(item) {
if (item.type !== 'file') return false;
if (item.content_type && item.content_type.startsWith('image/')) return true;
// Extension fallback
const ext = (item.name || '').split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'tiff', 'tif', 'svg'].includes(ext);
},
async loadDirectory(path) {
const fileList = document.getElementById('file-list');
fileList.innerHTML = '<p class="loading">Loading...</p>';
try {
const response = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error('Failed to load directory');
}
const data = await response.json();
this.currentPath = path;
this.saveCurrentPath();
this.renderBreadcrumb(path);
this.renderFileList(data.items || []);
} catch (error) {
console.error('Error loading directory:', error);
fileList.innerHTML = `<p class="error-state">Failed to load directory: ${error.message}</p>`;
}
},
renderBreadcrumb(path) {
const breadcrumb = document.getElementById('breadcrumb');
const parts = path.split('/').filter(p => p);
let html = '<a href="#" class="breadcrumb-item" data-path="/">Home</a>';
let currentPath = '';
parts.forEach((part, index) => {
currentPath += '/' + part;
html += ' / ';
if (index === parts.length - 1) {
html += `<span class="breadcrumb-item current">${part}</span>`;
} else {
html += `<a href="#" class="breadcrumb-item" data-path="${currentPath}">${part}</a>`;
}
});
breadcrumb.innerHTML = html;
// Add click handlers to breadcrumb links
breadcrumb.querySelectorAll('a.breadcrumb-item').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const path = link.dataset.path;
this.loadDirectory(path);
});
});
},
renderFileList(items) {
const fileList = document.getElementById('file-list');
if (items.length === 0) {
fileList.innerHTML = '<p class="empty-state">This folder is empty</p>';
this.renderSelectButton();
return;
}
// Sort: directories first, then files, alphabetically
const sortedItems = items.sort((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name);
}
return a.type === 'directory' ? -1 : 1;
});
// Build gallery images list from sorted items
this.galleryImages = [];
const imageIndexMap = {};
sortedItems.forEach((item, i) => {
if (this.isImageFile(item)) {
imageIndexMap[i] = this.galleryImages.length;
this.galleryImages.push(item);
}
});
let html = '<div class="file-items">';
sortedItems.forEach((item, i) => {
const isImage = this.isImageFile(item);
let icon;
if (item.type === 'directory') {
icon = '📁';
} else if (isImage) {
icon = '🖼️';
} else {
icon = '📄';
}
const sizeText = item.type === 'file' ? this.formatSize(item.size) : '';
const galleryAttr = isImage ? ` data-gallery-index="${imageIndexMap[i]}"` : '';
html += `
<div class="file-item ${item.type}" data-path="${item.path}" data-type="${item.type}"${galleryAttr}>
<span class="file-icon">${icon}</span>
<div class="file-info">
<div class="file-name">${this.escapeHtml(item.name)}</div>
${sizeText ? `<div class="file-size">${sizeText}</div>` : ''}
</div>
${item.type === 'directory' ? '<span class="chevron"></span>' : ''}
</div>
`;
});
html += '</div>';
fileList.innerHTML = html;
// Add click handlers
fileList.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
const path = item.dataset.path;
const type = item.dataset.type;
if (type === 'directory') {
this.loadDirectory(path);
} else if (item.dataset.galleryIndex !== undefined) {
this.openGallery(parseInt(item.dataset.galleryIndex, 10));
}
});
});
this.renderSelectButton();
},
renderSelectButton() {
const fileList = document.getElementById('file-list');
const selectBtn = document.createElement('button');
selectBtn.className = 'btn btn-primary btn-select-folder';
selectBtn.textContent = 'Select This Folder as Upload Destination';
selectBtn.addEventListener('click', () => this.selectCurrentFolder());
fileList.appendChild(selectBtn);
},
async selectCurrentFolder() {
localStorage.setItem('nextsnap_upload_path', this.currentPath);
const selectBtn = document.querySelector('.btn-select-folder');
const originalText = selectBtn.textContent;
selectBtn.textContent = '✓ Selected!';
selectBtn.disabled = true;
setTimeout(() => {
window.location.href = '/capture';
}, 1000);
},
async createNewFolder() {
const folderName = prompt('Enter folder name:');
if (!folderName || !folderName.trim()) {
return;
}
const trimmedName = folderName.trim();
if (/[\/\\?*"|<>:]/.test(trimmedName)) {
alert('Folder name contains invalid characters');
return;
}
const newPath = this.currentPath === '/'
? '/' + trimmedName
: this.currentPath + '/' + trimmedName;
try {
const response = await fetch('/api/files/mkdir', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: newPath })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create folder');
}
this.loadDirectory(this.currentPath);
} catch (error) {
console.error('Error creating folder:', error);
alert('Failed to create folder: ' + error.message);
}
},
// ---- Gallery methods ----
openGallery(index) {
if (this.galleryImages.length === 0) return;
this.galleryOpen = true;
this.galleryIndex = index;
this.galleryPreloaded = {};
document.body.classList.add('gallery-open');
document.getElementById('gallery-overlay').style.display = 'flex';
this.displayGalleryImage(index);
},
closeGallery() {
this.galleryOpen = false;
document.body.classList.remove('gallery-open');
document.getElementById('gallery-overlay').style.display = 'none';
// Revoke preloaded object URLs
Object.values(this.galleryPreloaded).forEach(url => {
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
});
this.galleryPreloaded = {};
// Clear image src
const img = document.getElementById('gallery-image');
if (img.src && img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = '';
},
async displayGalleryImage(index) {
if (index < 0 || index >= this.galleryImages.length) return;
this.galleryIndex = index;
const item = this.galleryImages[index];
const img = document.getElementById('gallery-image');
const spinner = document.getElementById('gallery-spinner');
// Show spinner, hide image
spinner.style.display = 'flex';
img.style.display = 'none';
// Update counter and filename
document.getElementById('gallery-counter').textContent = (index + 1) + ' / ' + this.galleryImages.length;
document.getElementById('gallery-filename').textContent = item.name;
// Update nav button states
document.getElementById('gallery-prev-btn').disabled = (index === 0);
document.getElementById('gallery-next-btn').disabled = (index === this.galleryImages.length - 1);
// Load image
let src = this.galleryPreloaded[index];
if (!src) {
src = await this.fetchGalleryImage(item);
this.galleryPreloaded[index] = src;
}
// Only update if we're still on this index (user may have swiped away)
if (this.galleryIndex !== index) return;
img.onload = () => {
spinner.style.display = 'none';
img.style.display = 'block';
};
img.onerror = () => {
spinner.style.display = 'none';
img.style.display = 'block';
};
img.src = src;
// Preload adjacent
this.preloadGalleryAdjacent(index);
},
async fetchGalleryImage(item) {
const path = item.path || (this.currentPath + '/' + item.name);
const url = '/api/files/thumbnail?path=' + encodeURIComponent(path) + '&size=1024';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch image');
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.error('Error fetching gallery image:', error);
return '';
}
},
preloadGalleryAdjacent(index) {
const toPreload = [index - 1, index + 1, index + 2];
toPreload.forEach(async (i) => {
if (i >= 0 && i < this.galleryImages.length && !this.galleryPreloaded[i]) {
this.galleryPreloaded[i] = await this.fetchGalleryImage(this.galleryImages[i]);
}
});
},
galleryPrev() {
if (this.galleryIndex > 0) {
this.displayGalleryImage(this.galleryIndex - 1);
}
},
galleryNext() {
if (this.galleryIndex < this.galleryImages.length - 1) {
this.displayGalleryImage(this.galleryIndex + 1);
}
},
handleGallerySwipe() {
const deltaX = this.galleryTouchEndX - this.galleryTouchStartX;
const deltaY = this.galleryTouchEndY - this.galleryTouchStartY;
// Ignore if vertical swipe is dominant
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
// Ignore if too small
if (Math.abs(deltaX) < 50) return;
if (deltaX > 0) this.galleryPrev();
else this.galleryNext();
},
openRenameModal() {
const item = this.galleryImages[this.galleryIndex];
if (!item) return;
const name = item.name;
const lastDot = name.lastIndexOf('.');
let basename, ext;
if (lastDot > 0) {
basename = name.substring(0, lastDot);
ext = name.substring(lastDot);
} else {
basename = name;
ext = '';
}
document.getElementById('gallery-rename-input').value = basename;
document.getElementById('gallery-rename-ext').textContent = ext;
document.getElementById('gallery-rename-error').textContent = '';
document.getElementById('gallery-rename-backdrop').style.display = 'flex';
// Focus input after display
setTimeout(() => {
const input = document.getElementById('gallery-rename-input');
input.focus();
input.select();
}, 100);
},
closeRenameModal() {
document.getElementById('gallery-rename-backdrop').style.display = 'none';
},
async saveRename() {
const item = this.galleryImages[this.galleryIndex];
if (!item) return;
const input = document.getElementById('gallery-rename-input');
const ext = document.getElementById('gallery-rename-ext').textContent;
const errorEl = document.getElementById('gallery-rename-error');
const newBasename = input.value.trim();
// Validate
if (!newBasename) {
errorEl.textContent = 'Filename cannot be empty';
return;
}
if (/[\/\\?*"|<>:]/.test(newBasename)) {
errorEl.textContent = 'Invalid characters in filename';
return;
}
if (newBasename.length > 200) {
errorEl.textContent = 'Filename too long (max 200 characters)';
return;
}
const newName = newBasename + ext;
// If name hasn't changed, just close
if (newName === item.name) {
this.closeRenameModal();
return;
}
const saveBtn = document.getElementById('gallery-rename-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const sourcePath = item.path || (this.currentPath + '/' + item.name);
const destPath = this.currentPath + '/' + newName;
const response = await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: sourcePath, destination: destPath })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Rename failed');
}
// Update local state
item.name = newName;
item.path = destPath;
document.getElementById('gallery-filename').textContent = newName;
this.closeRenameModal();
this.showGalleryToast('File renamed', 'success');
// Refresh the file list in the background
this.loadDirectory(this.currentPath);
} catch (error) {
console.error('Error renaming file:', error);
errorEl.textContent = error.message;
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
},
showGalleryToast(msg, type) {
const toast = document.getElementById('gallery-toast');
toast.textContent = msg;
toast.className = 'gallery-toast ' + type;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 2500);
},
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
saveCurrentPath() {
localStorage.setItem('nextsnap_current_browse_path', this.currentPath);
},
loadCurrentPath() {
const saved = localStorage.getItem('nextsnap_current_browse_path');
if (saved) {
this.currentPath = saved;
}
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => FileBrowser.init());
} else {
FileBrowser.init();
}

300
app/static/js/polish.js Normal file
View File

@@ -0,0 +1,300 @@
// NextSnap - Polish & UX Enhancements
'use strict';
const Polish = {
init() {
this.setupLoadingIndicator();
this.setupSmoothScroll();
this.setupFormValidation();
this.setupKeyboardShortcuts();
this.setupOfflineDetection();
this.setupImageLazyLoading();
console.log('Polish enhancements loaded');
},
// Global loading indicator
setupLoadingIndicator() {
this.loadingBar = document.createElement('div');
this.loadingBar.className = 'loading-bar';
this.loadingBar.style.display = 'none';
document.body.appendChild(this.loadingBar);
},
showLoading() {
if (this.loadingBar) {
this.loadingBar.style.display = 'block';
}
},
hideLoading() {
if (this.loadingBar) {
this.loadingBar.style.display = 'none';
}
},
// Smooth scroll for anchor links
setupSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', (e) => {
const href = anchor.getAttribute('href');
if (href === '#') return;
e.preventDefault();
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
},
// Enhanced form validation
setupFormValidation() {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
invalidInputs[0].focus();
this.shake(invalidInputs[0]);
}
});
});
// Real-time validation feedback
document.querySelectorAll('input[required], textarea[required]').forEach(input => {
input.addEventListener('blur', () => {
if (!input.validity.valid) {
this.shake(input);
}
});
});
},
// Shake animation for errors
shake(element) {
element.style.animation = 'inputShake 0.3s';
setTimeout(() => {
element.style.animation = '';
}, 300);
},
// Keyboard shortcuts
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Alt+1: Go to capture
if (e.altKey && e.key === '1') {
e.preventDefault();
window.location.href = '/capture';
}
// Alt+2: Go to queue
if (e.altKey && e.key === '2') {
e.preventDefault();
window.location.href = '/queue';
}
// Alt+3: Go to files
if (e.altKey && e.key === '3') {
e.preventDefault();
window.location.href = '/browser';
}
// Alt+R: Refresh/sync
if (e.altKey && e.key === 'r') {
e.preventDefault();
if (window.SyncEngine && window.SyncEngine.triggerSync) {
window.SyncEngine.triggerSync();
}
}
});
},
// Enhanced offline detection
setupOfflineDetection() {
let wasOffline = !navigator.onLine;
window.addEventListener('online', () => {
if (wasOffline) {
this.showToast('✓ Back online', 'success', 2000);
wasOffline = false;
}
});
window.addEventListener('offline', () => {
this.showToast('⚠️ You are offline', 'warning', 3000);
wasOffline = true;
});
},
// Lazy loading for images
setupImageLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
},
// Toast notification helper
showToast(message, type = 'info', duration = 3000) {
let toast = document.getElementById('global-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'global-toast';
toast.className = 'toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = `toast ${type}`;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, duration);
},
// Confirm dialog with better UX
async confirm(message, title = 'Confirm') {
return new Promise((resolve) => {
const modal = this.createConfirmModal(message, title);
document.body.appendChild(modal);
modal.querySelector('.confirm-yes').onclick = () => {
document.body.removeChild(modal);
resolve(true);
};
modal.querySelector('.confirm-no').onclick = () => {
document.body.removeChild(modal);
resolve(false);
};
modal.onclick = (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
resolve(false);
}
};
});
},
createConfirmModal(message, title) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<h3>${this.escapeHtml(title)}</h3>
<p>${this.escapeHtml(message)}</p>
<div class="modal-actions">
<button class="btn btn-secondary confirm-no">Cancel</button>
<button class="btn btn-primary confirm-yes">Confirm</button>
</div>
</div>
`;
return modal;
},
// Copy to clipboard with feedback
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('✓ Copied to clipboard', 'success', 1500);
return true;
} catch (err) {
this.showToast('❌ Failed to copy', 'error', 2000);
return false;
}
},
// Debounce helper
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Throttle helper
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
// Escape HTML
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// Format file size
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
// Format date relative
formatRelativeTime(date) {
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
},
// Vibrate if supported (for mobile feedback)
vibrate(pattern = 50) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
};
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => Polish.init());
} else {
Polish.init();
}
// Make Polish globally available
window.Polish = Polish;

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;

152
app/static/js/storage.js Normal file
View File

@@ -0,0 +1,152 @@
// NextSnap - IndexedDB Storage using Dexie.js
'use strict';
const Storage = {
db: null,
init() {
// Initialize Dexie database
this.db = new Dexie('nextsnap');
// Define schema with compound indexes
this.db.version(1).stores({
photos: '++id, username, timestamp, filename, targetPath, status, [username+status], [username+timestamp]',
settings: '++id, username, [username+key]'
});
return this.db.open();
},
async savePhoto(photoData) {
/**
* Save a photo to IndexedDB
* photoData: {
* username: string,
* timestamp: number,
* filename: string,
* targetPath: string,
* blob: Blob,
* status: 'pending' | 'uploading' | 'uploaded' | 'verified'
* retryCount: number,
* lastError: string
* }
*/
const id = await this.db.photos.add({
username: photoData.username,
timestamp: photoData.timestamp,
filename: photoData.filename,
targetPath: photoData.targetPath,
blob: photoData.blob,
status: photoData.status || 'pending',
retryCount: photoData.retryCount || 0,
lastError: photoData.lastError || null
});
return id;
},
async getPhoto(id) {
return await this.db.photos.get(id);
},
async getAllPhotos(username = null) {
if (username) {
return await this.db.photos
.where('username').equals(username)
.reverse()
.sortBy('timestamp');
}
return await this.db.photos.reverse().sortBy('timestamp');
},
async getPendingPhotos(username = null) {
if (username) {
return await this.db.photos
.where('[username+status]')
.equals([username, 'pending'])
.sortBy('timestamp');
}
return await this.db.photos
.where('status').equals('pending')
.sortBy('timestamp');
},
async getRecentPhotos(username, limit = 5) {
return await this.db.photos
.where('username').equals(username)
.reverse()
.limit(limit)
.sortBy('timestamp');
},
async updatePhoto(id, updates) {
return await this.db.photos.update(id, updates);
},
async deletePhoto(id) {
return await this.db.photos.delete(id);
},
async getPhotoCount(username = null, status = null) {
let collection = this.db.photos;
if (username && status) {
return await collection
.where('[username+status]')
.equals([username, status])
.count();
} else if (username) {
return await collection
.where('username').equals(username)
.count();
} else if (status) {
return await collection
.where('status').equals(status)
.count();
}
return await collection.count();
},
async saveSetting(username, key, value) {
// Check if setting exists
const existing = await this.db.settings
.where('[username+key]')
.equals([username, key])
.first();
if (existing) {
await this.db.settings.update(existing.id, { value: value });
} else {
await this.db.settings.add({
username: username,
key: key,
value: value
});
}
},
async getSetting(username, key, defaultValue = null) {
const setting = await this.db.settings
.where('[username+key]')
.equals([username, key])
.first();
return setting ? setting.value : defaultValue;
},
async clearVerifiedPhotos(username = null) {
if (username) {
return await this.db.photos
.where('[username+status]')
.equals([username, 'verified'])
.delete();
}
return await this.db.photos
.where('status').equals('verified')
.delete();
}
};
// Make Storage globally available
window.Storage = Storage;

300
app/static/js/sync.js Normal file
View File

@@ -0,0 +1,300 @@
// NextSnap - Sync Engine
'use strict';
console.log('[SYNC] Loading sync.js...');
const Sync = {
currentUsername: null,
isOnline: navigator.onLine,
isSyncing: false,
MAX_RETRIES: 5,
VERIFY_DELAY_MS: 800,
init(username) {
this.currentUsername = username;
this.setupEventListeners();
console.log('[SYNC] Initialized for:', username);
if (this.isOnline) {
this.triggerSync();
}
},
setupEventListeners() {
window.addEventListener('online', () => {
console.log('[SYNC] Network online');
this.isOnline = true;
this.triggerSync();
});
window.addEventListener('offline', () => {
console.log('[SYNC] Network offline');
this.isOnline = false;
this.isSyncing = false;
});
},
// Prevent page navigation during active upload
_setUploading(active) {
if (active) {
this._beforeUnloadHandler = (e) => {
e.preventDefault();
e.returnValue = 'Upload in progress - leaving will cancel it.';
return e.returnValue;
};
window.addEventListener('beforeunload', this._beforeUnloadHandler);
} else {
if (this._beforeUnloadHandler) {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
this._beforeUnloadHandler = null;
}
}
},
async triggerSync() {
if (!this.isOnline || this.isSyncing) {
console.log('[SYNC] Skip sync - online:', this.isOnline, 'syncing:', this.isSyncing);
return;
}
console.log('[SYNC] Starting sync...');
this.isSyncing = true;
try {
await this.processQueue();
} catch (error) {
console.error('[SYNC] Error:', error);
} finally {
this.isSyncing = false;
this._setUploading(false);
}
},
async processQueue() {
const pendingPhotos = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
.sortBy('timestamp');
console.log('[SYNC] Found', pendingPhotos.length, 'photos to process');
if (pendingPhotos.length === 0) {
return;
}
for (const photo of pendingPhotos) {
if (!this.isOnline) {
console.log('[SYNC] Lost connection, stopping');
break;
}
// Skip photos that have exceeded max retries
const retryCount = photo.retryCount || 0;
if (retryCount >= this.MAX_RETRIES) {
console.warn('[SYNC] Skipping photo (max retries reached):', photo.filename, 'retries:', retryCount);
await Storage.updatePhoto(photo.id, { status: 'failed' });
continue;
}
await this.uploadPhoto(photo);
}
},
async uploadPhoto(photo) {
const retryCount = photo.retryCount || 0;
try {
console.log('[SYNC] Uploading:', photo.filename, '(attempt', retryCount + 1, 'of', this.MAX_RETRIES + ')');
// Validate blob before attempting upload
if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) {
throw new Error('Photo data is missing or corrupted - please delete and re-capture');
}
await Storage.updatePhoto(photo.id, { status: 'uploading' });
// Prevent page navigation during upload
this._setUploading(true);
// Only check for duplicates on retries (skip on first attempt to reduce latency)
if (retryCount > 0) {
const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename;
const alreadyExists = await this.checkFileExists(fullPath);
if (alreadyExists) {
console.log('[SYNC] File already exists on server, skipping upload:', fullPath);
await Storage.updatePhoto(photo.id, {
status: 'verified',
blob: null,
completedAt: Date.now()
});
await this.pruneHistory();
this._setUploading(false);
return;
}
}
// Resize if over 10MB
let uploadBlob = photo.blob;
if (uploadBlob.size > 10 * 1024 * 1024) {
console.log('[SYNC] Photo too large (' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB), resizing...');
uploadBlob = await this.resizeImage(uploadBlob, 10 * 1024 * 1024);
console.log('[SYNC] Resized to ' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB');
}
const formData = new FormData();
formData.append('file', uploadBlob, photo.filename);
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
let errorMsg = 'Upload failed';
try {
const errData = await uploadResponse.json();
errorMsg = errData.error || errorMsg;
} catch (e) {
errorMsg = 'Upload failed (HTTP ' + uploadResponse.status + ')';
}
throw new Error(errorMsg);
}
const uploadResult = await uploadResponse.json();
console.log('[SYNC] Upload successful:', uploadResult.path);
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
// Wait before verifying to allow server-side processing
await this.delay(this.VERIFY_DELAY_MS);
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(uploadResult.path);
const verifyResponse = await fetch(verifyUrl);
if (!verifyResponse.ok) {
throw new Error('Verification failed');
}
const verifyResult = await verifyResponse.json();
if (!verifyResult.exists) {
throw new Error('File not found on server');
}
console.log('[SYNC] Verified:', uploadResult.path);
// Keep record but strip blob to save storage
await Storage.updatePhoto(photo.id, {
status: 'verified',
blob: null,
completedAt: Date.now()
});
console.log('[SYNC] Upload complete:', photo.id);
// Prune old completed entries beyond 20
await this.pruneHistory();
// Clear navigation guard after successful upload
this._setUploading(false);
} catch (error) {
this._setUploading(false);
console.error('[SYNC] Upload failed:', error, '(attempt', retryCount + 1 + ')');
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount + 1,
lastError: error.message,
error: error.message
});
}
},
async resizeImage(blob, maxBytes) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
URL.revokeObjectURL(url);
let quality = 0.85;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((result) => {
if (!result) {
reject(new Error('Failed to resize image'));
return;
}
if (result.size <= maxBytes || (quality <= 0.4 && scale <= 0.3)) {
resolve(result);
} else {
// Reduce quality first, then scale down
if (quality > 0.5) {
quality -= 0.1;
} else {
scale *= 0.8;
quality = 0.7;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image for resize'));
};
img.src = url;
});
},
async pruneHistory() {
// Keep only the last 20 completed (verified/failed) entries per user
const completed = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(p => p.status === 'verified' || p.status === 'failed')
.sortBy('timestamp');
if (completed.length > 20) {
const toDelete = completed.slice(0, completed.length - 20);
for (const photo of toDelete) {
await Storage.deletePhoto(photo.id);
}
console.log('[SYNC] Pruned', toDelete.length, 'old history entries');
}
},
async checkFileExists(path) {
try {
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path);
const response = await fetch(verifyUrl);
if (!response.ok) return false;
const result = await response.json();
return result.exists === true;
} catch (e) {
// If we can't check, assume it doesn't exist and proceed with upload
return false;
}
},
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
window.SyncEngine = Sync;
console.log('[SYNC] SyncEngine exported successfully');

View File

@@ -0,0 +1,242 @@
// SYNC.JS VERSION 8 - LOADING
console.log("[SYNC] Loading sync.js v8...");
// NextSnap - Sync Engine with Upload Queue and Retry Logic
'use strict';
const Sync = {
currentUsername: null,
isOnline: navigator.onLine,
isSyncing: false,
currentUpload: null,
retryTimeouts: {},
// Exponential backoff delays (in milliseconds)
retryDelays: [5000, 15000, 45000, 120000, 300000], // 5s, 15s, 45s, 2m, 5m
maxRetryDelay: 300000, // Cap at 5 minutes
init(username) {
this.currentUsername = username;
this.setupEventListeners();
// Check for pending uploads on init
if (this.isOnline) {
this.triggerSync();
}
},
setupEventListeners() {
// Listen for online/offline events
window.addEventListener('online', () => {
console.log('Network online - triggering sync');
this.isOnline = true;
this.updateConnectivityUI();
this.triggerSync();
});
window.addEventListener('offline', () => {
console.log('Network offline');
this.isOnline = false;
this.isSyncing = false;
this.updateConnectivityUI();
});
},
async triggerSync() {
if (!this.isOnline || this.isSyncing) {
return;
}
console.log('Starting sync...');
this.isSyncing = true;
this.updateConnectivityUI();
try {
await this.processQueue();
} catch (error) {
console.error('Sync error:', error);
} finally {
this.isSyncing = false;
this.updateConnectivityUI();
}
},
async processQueue() {
// Get pending and uploading photos (retry stalled uploads)
const pendingPhotos = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
.sortBy('timestamp');
if (pendingPhotos.length === 0) {
console.log('No pending photos to upload');
return;
}
console.log(`Found ${pendingPhotos.length} photos to upload`);
// Process uploads sequentially (one at a time)
for (const photo of pendingPhotos) {
if (!this.isOnline) {
console.log('Lost connection - stopping sync');
break;
}
await this.uploadPhoto(photo);
}
// Update UI
this.updatePendingCount();
this.updateRecentPhotos();
},
async uploadPhoto(photo) {
this.currentUpload = photo;
try {
console.log(`Uploading ${photo.filename}...`);
// Update status to uploading
await Storage.updatePhoto(photo.id, { status: 'uploading' });
this.updatePendingCount();
// Upload file
const formData = new FormData();
formData.append('file', photo.blob, photo.filename);
const uploadUrl = `/api/files/upload?path=${encodeURIComponent(photo.targetPath)}`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json();
throw new Error(error.error || 'Upload failed');
}
const uploadResult = await uploadResponse.json();
console.log(`Upload successful: ${uploadResult.path}`);
// Update status to uploaded
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
// Verify file exists on server
const verifyUrl = `/api/files/verify?path=${encodeURIComponent(uploadResult.path)}`;
const verifyResponse = await fetch(verifyUrl);
if (!verifyResponse.ok) {
throw new Error('Verification failed - file not found on server');
}
const verifyResult = await verifyResponse.json();
if (!verifyResult.exists) {
throw new Error('Verification failed - file does not exist');
}
console.log(`Verification successful: ${uploadResult.path}`);
// Update status to verified
await Storage.updatePhoto(photo.id, { status: 'verified' });
// Delete from IndexedDB (only after verification!)
await Storage.deletePhoto(photo.id);
console.log(`Deleted photo ${photo.id} from IndexedDB`);
// Clear any pending retry
if (this.retryTimeouts[photo.id]) {
clearTimeout(this.retryTimeouts[photo.id]);
delete this.retryTimeouts[photo.id];
}
} catch (error) {
console.error(`Error uploading ${photo.filename}:`, error);
// Handle upload failure
await this.handleUploadFailure(photo, error.message);
} finally {
this.currentUpload = null;
}
},
async handleUploadFailure(photo, errorMessage) {
const retryCount = (photo.retryCount || 0) + 1;
// Update photo with error info
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount,
lastError: errorMessage
});
// Calculate retry delay using exponential backoff
const delayIndex = Math.min(retryCount - 1, this.retryDelays.length - 1);
const delay = this.retryDelays[delayIndex];
console.log(`Scheduling retry #${retryCount} in ${delay / 1000}s for ${photo.filename}`);
// Schedule retry
if (this.retryTimeouts[photo.id]) {
clearTimeout(this.retryTimeouts[photo.id]);
}
this.retryTimeouts[photo.id] = setTimeout(() => {
delete this.retryTimeouts[photo.id];
if (this.isOnline) {
console.log(`Retrying upload for ${photo.filename}`);
this.uploadPhoto(photo);
}
}, delay);
},
async updateRecentPhotos() {
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
await Camera.updateRecentPhotos();
}
},
updateConnectivityUI() {
const indicator = document.querySelector(".connectivity-indicator");
if (!indicator) return;
indicator.classList.remove("online", "offline", "syncing");
if (!this.isOnline) {
indicator.classList.add("offline");
indicator.title = "Offline";
} else if (this.isSyncing) {
indicator.classList.add("syncing");
indicator.title = "Syncing...";
} else {
indicator.classList.add("online");
indicator.title = "Online";
}
},
async updatePendingCount() {
if (!this.currentUsername) return;
const countElement = document.getElementById("pendingCount");
const countValueElement = document.getElementById("pendingCountValue");
if (!countElement || !countValueElement) return;
const count = await Storage.getPhotoCount(this.currentUsername, "pending");
if (count > 0) {
countValueElement.textContent = count;
countElement.style.display = "block";
} else {
countElement.style.display = "none";
}
},
},
getState() {
return {
isOnline: this.isOnline,
isSyncing: this.isSyncing,
};
}
// Make Sync globally available as SyncEngine
window.SyncEngine = Sync;
console.log("[SYNC] SyncEngine exported:", typeof window.SyncEngine);

View File

@@ -0,0 +1,6 @@
async updateRecentPhotos() {
// Delegate to Camera module if available
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
await Camera.updateRecentPhotos();
}
},