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>
408 lines
12 KiB
HTML
408 lines
12 KiB
HTML
{% 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 %}
|