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

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`;
}
};