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>
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
// 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`;
|
|
}
|
|
};
|