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

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);