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

407
app/templates/capture.html Normal file
View File

@@ -0,0 +1,407 @@
{% extends "base.html" %}
{% block title %}Capture - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="capture-section">
<p class="upload-path">
Uploading to: <strong><span id="current-path">/</span></strong>
<a href="/browser" class="change-link">Change</a>
</p>
<button class="btn btn-primary btn-capture" id="capture-btn">
📷 Take Photo
</button>
<input
type="file"
id="camera-input"
accept="image/*"
capture="environment"
style="display: none;"
>
<!-- Session counter shown during continuous capture -->
<div class="capture-session-bar" id="capture-session-bar" style="display:none">
<span id="session-count">0</span> photos this session
</div>
</div>
<div class="recent-photos" id="recent-photos">
<h3>Recent Photos</h3>
<div class="photo-thumbnails" id="photo-thumbnails">
<p class="empty-state">No photos captured yet</p>
</div>
</div>
<!-- Capture toast -->
<div class="capture-toast" id="capture-toast"></div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.capture-section {
text-align: center;
margin: 2rem 0;
}
.upload-path {
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.upload-path strong {
color: var(--text-primary);
}
.change-link {
color: var(--accent);
margin-left: 0.5rem;
text-decoration: none;
}
.change-link:hover {
text-decoration: underline;
}
.btn-capture {
font-size: 1.25rem;
padding: 1.5rem;
margin: 1rem 0;
}
.btn-capture:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.capture-session-bar {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-radius: 8px;
color: var(--accent);
font-weight: 500;
font-size: 0.95rem;
display: inline-block;
}
.capture-toast {
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9998;
display: none;
white-space: nowrap;
background: rgba(40, 167, 69, 0.9);
color: #fff;
pointer-events: none;
}
.recent-photos {
margin-top: 3rem;
padding-bottom: 2rem;
}
.recent-photos h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.photo-thumbnails {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.photo-thumbnails .empty-state {
grid-column: 1 / -1;
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem;
font-size: 0.9rem;
}
.thumbnail {
aspect-ratio: 1;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail .status-badge {
position: absolute;
top: 4px;
right: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--bg-primary);
}
.thumbnail .status-badge.pending {
background: var(--warning);
}
.thumbnail .status-badge.uploading {
background: var(--accent);
animation: pulse 1s infinite;
}
.thumbnail .status-badge.verified {
background: var(--success);
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
const currentUsername = '{{ username }}';
function updateUploadPath() {
const savedPath = localStorage.getItem('nextsnap_upload_path') || '/';
document.getElementById('current-path').textContent = savedPath;
}
updateUploadPath();
let storageReady = false;
Storage.init().then(() => {
storageReady = true;
SyncEngine.init(currentUsername);
loadRecentPhotos();
}).catch(err => console.error('Storage init failed:', err));
const captureBtn = document.getElementById('capture-btn');
const cameraInput = document.getElementById('camera-input');
// Continuous capture state
let sessionCount = 0;
let continuousCapture = false;
let processingPhoto = false;
captureBtn.addEventListener('click', function() {
sessionCount = 0;
updateSessionCounter();
cameraInput.click();
});
cameraInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) {
// User cancelled the camera — stop continuous capture
continuousCapture = false;
if (sessionCount > 0) {
showCaptureToast(sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') + ' captured');
}
return;
}
processingPhoto = true;
captureBtn.disabled = true;
captureBtn.textContent = '⏳ Processing...';
try {
const jpegBlob = await convertToJPEG(file);
const now = new Date();
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') + '_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
const filename = currentUsername + '_' + timestamp + '.jpg';
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
if (storageReady) {
await Storage.savePhoto({
username: currentUsername,
timestamp: Date.now(),
filename: filename,
targetPath: targetPath,
blob: jpegBlob,
status: 'pending'
});
if (navigator.onLine && typeof SyncEngine !== 'undefined') {
SyncEngine.triggerSync();
}
loadRecentPhotos();
}
sessionCount++;
continuousCapture = true;
updateSessionCounter();
showCaptureToast('Photo saved');
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
} catch (error) {
console.error('Error:', error);
captureBtn.textContent = '❌ Error';
continuousCapture = false;
setTimeout(() => {
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
}, 2000);
alert('Failed: ' + error.message);
}
processingPhoto = false;
e.target.value = '';
// Re-open camera automatically for next photo
if (continuousCapture) {
setTimeout(() => {
cameraInput.click();
}, 300);
}
});
function updateSessionCounter() {
const bar = document.getElementById('capture-session-bar');
const countEl = document.getElementById('session-count');
if (sessionCount > 0) {
countEl.textContent = sessionCount;
bar.style.display = 'inline-block';
} else {
bar.style.display = 'none';
}
}
function showCaptureToast(msg) {
const toast = document.getElementById('capture-toast');
toast.textContent = '✓ ' + msg;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 1500);
}
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB
function convertToJPEG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let quality = 0.92;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to convert'));
return;
}
if (blob.size <= MAX_UPLOAD_BYTES || quality <= 0.3) {
console.log('Photo: ' + (blob.size / 1024 / 1024).toFixed(1) + 'MB, ' + canvas.width + 'x' + canvas.height + ', q=' + quality.toFixed(2));
resolve(blob);
} else {
console.log('Photo too large (' + (blob.size / 1024 / 1024).toFixed(1) + 'MB), reducing...');
if (quality > 0.5) {
quality -= 0.1;
} else {
scale *= 0.8;
quality = 0.7;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => reject(new Error('Failed to load'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read'));
reader.readAsDataURL(file);
});
}
async function loadRecentPhotos() {
if (!storageReady) return;
const container = document.getElementById('photo-thumbnails');
const photos = await Storage.getRecentPhotos(currentUsername, 5);
if (photos.length === 0) {
container.innerHTML = '<p class="empty-state">No photos captured yet</p>';
return;
}
container.innerHTML = '';
for (const photo of photos) {
const url = URL.createObjectURL(photo.blob);
const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'uploading' ? 'uploading' : 'pending';
const div = document.createElement('div');
div.className = 'thumbnail';
div.innerHTML = '<img src="' + url + '"><span class="status-badge ' + statusClass + '"></span>';
container.appendChild(div);
}
}
console.log('Capture page loaded');
// Debug panel
setTimeout(() => {
const debugDiv = document.createElement('div');
debugDiv.id = 'debug-panel';
debugDiv.style.cssText = 'position:fixed;bottom:80px;right:10px;z-index:9999;background:black;color:lime;padding:10px;border-radius:5px;font-family:monospace;font-size:10px;max-width:300px;';
const status = [];
status.push('Storage: ' + (typeof Storage !== 'undefined' ? 'OK' : 'FAIL'));
status.push('SyncEngine: ' + (typeof SyncEngine !== 'undefined' ? 'OK' : 'FAIL'));
status.push('Dexie: ' + (typeof Dexie !== 'undefined' ? 'OK' : 'FAIL'));
debugDiv.innerHTML = '<div style="margin-bottom:5px;">' + status.join('<br>') + '</div>' +
'<button onclick="manualSync()" style="background:lime;color:black;border:none;padding:10px;border-radius:5px;cursor:pointer;font-weight:bold;width:100%;">FORCE SYNC</button>' +
'<div id="sync-status" style="margin-top:5px;color:yellow;"></div>';
document.body.appendChild(debugDiv);
}, 2000);
window.manualSync = function() {
const statusDiv = document.getElementById('sync-status');
statusDiv.textContent = 'Checking...';
if (typeof SyncEngine === 'undefined') {
statusDiv.textContent = 'ERROR: SyncEngine not loaded!';
return;
}
statusDiv.textContent = 'Triggering sync...';
try {
SyncEngine.triggerSync();
statusDiv.textContent = 'Sync triggered!';
} catch (e) {
statusDiv.textContent = 'ERROR: ' + e.message;
}
};
</script>
{% endblock %}