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:
318
app/static/js/camera.js
Normal file
318
app/static/js/camera.js
Normal 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`;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user