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

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;