Adds an "Upload from Library" button below the camera button that opens the device photo picker (no capture attribute) with multi-select support. Selected photos are converted to JPEG and queued for upload, with a progress counter during processing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
647 lines
17 KiB
HTML
647 lines
17 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>
|
|
|
|
<button class="btn btn-secondary btn-upload" id="upload-btn">
|
|
🖼️ Upload from Library
|
|
</button>
|
|
|
|
<!-- Hidden file input as fallback for camera -->
|
|
<input
|
|
type="file"
|
|
id="camera-input"
|
|
accept="image/*"
|
|
capture="environment"
|
|
style="display: none;"
|
|
>
|
|
|
|
<!-- Hidden file input for photo library (multiple) -->
|
|
<input
|
|
type="file"
|
|
id="upload-input"
|
|
accept="image/*"
|
|
multiple
|
|
style="display: none;"
|
|
>
|
|
</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>
|
|
</div>
|
|
|
|
<!-- Fullscreen Camera Viewfinder -->
|
|
<div class="camera-viewfinder" id="camera-viewfinder" style="display:none">
|
|
<video id="camera-video" autoplay playsinline muted></video>
|
|
|
|
<!-- Flash overlay for shutter feedback -->
|
|
<div class="camera-flash" id="camera-flash"></div>
|
|
|
|
<div class="camera-top-bar">
|
|
<button class="camera-ui-btn camera-close-btn" id="camera-close-btn">×</button>
|
|
<span class="camera-counter" id="camera-counter"></span>
|
|
<div style="width:48px"></div>
|
|
</div>
|
|
|
|
<div class="camera-bottom-bar">
|
|
<div style="width:48px"></div>
|
|
<button class="camera-shutter-btn" id="camera-shutter-btn">
|
|
<span class="shutter-ring"></span>
|
|
</button>
|
|
<button class="camera-ui-btn camera-flip-btn" id="camera-flip-btn">🔄</button>
|
|
</div>
|
|
|
|
<!-- Hidden canvas for frame capture -->
|
|
<canvas id="camera-canvas" style="display:none"></canvas>
|
|
|
|
<!-- Toast inside viewfinder -->
|
|
<div class="camera-toast" id="camera-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;
|
|
}
|
|
|
|
.btn-upload {
|
|
font-size: 1.1rem;
|
|
padding: 1rem;
|
|
margin: 0.5rem 0;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--bg-tertiary);
|
|
}
|
|
|
|
.btn-upload:disabled {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* Camera Viewfinder */
|
|
body.camera-open {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.camera-viewfinder {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: #000;
|
|
z-index: 9999;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.camera-viewfinder video {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.camera-flash {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: #fff;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
transition: opacity 0.05s;
|
|
}
|
|
|
|
.camera-flash.flash {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.camera-top-bar {
|
|
position: relative;
|
|
z-index: 3;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 1rem;
|
|
padding-top: max(0.5rem, env(safe-area-inset-top));
|
|
background: rgba(0,0,0,0.4);
|
|
}
|
|
|
|
.camera-ui-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #fff;
|
|
font-size: 1.5rem;
|
|
padding: 0.5rem;
|
|
min-width: 48px;
|
|
min-height: 48px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.camera-close-btn {
|
|
font-size: 2.2rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.camera-counter {
|
|
color: #fff;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.camera-bottom-bar {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 3;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.5rem 2rem;
|
|
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
|
|
background: rgba(0,0,0,0.4);
|
|
}
|
|
|
|
.camera-shutter-btn {
|
|
width: 72px;
|
|
height: 72px;
|
|
border-radius: 50%;
|
|
border: 4px solid #fff;
|
|
background: rgba(255,255,255,0.2);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.camera-shutter-btn:active {
|
|
transform: scale(0.92);
|
|
}
|
|
|
|
.camera-shutter-btn:disabled {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.shutter-ring {
|
|
display: block;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: #fff;
|
|
}
|
|
|
|
.camera-flip-btn {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.camera-toast {
|
|
position: absolute;
|
|
bottom: 140px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 0.5rem 1.25rem;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
z-index: 4;
|
|
display: none;
|
|
white-space: nowrap;
|
|
background: rgba(40, 167, 69, 0.85);
|
|
color: #fff;
|
|
pointer-events: none;
|
|
}
|
|
</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');
|
|
|
|
// ---- Camera state ----
|
|
let cameraStream = null;
|
|
let facingMode = 'environment';
|
|
let sessionCount = 0;
|
|
let shutterBusy = false;
|
|
|
|
// ---- Main button: open live viewfinder, fallback to file input ----
|
|
captureBtn.addEventListener('click', async function() {
|
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
await openCamera();
|
|
} else {
|
|
// Fallback: use file input
|
|
cameraInput.click();
|
|
}
|
|
});
|
|
|
|
// ---- File input fallback (single-shot) ----
|
|
cameraInput.addEventListener('change', async function(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
captureBtn.disabled = true;
|
|
captureBtn.textContent = '⏳ Processing...';
|
|
|
|
try {
|
|
const jpegBlob = await convertToJPEG(file);
|
|
await savePhoto(jpegBlob);
|
|
captureBtn.textContent = '✓ Photo Saved!';
|
|
loadRecentPhotos();
|
|
setTimeout(() => {
|
|
captureBtn.disabled = false;
|
|
captureBtn.textContent = '📷 Take Photo';
|
|
}, 1000);
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
alert('Failed: ' + error.message);
|
|
captureBtn.disabled = false;
|
|
captureBtn.textContent = '📷 Take Photo';
|
|
}
|
|
|
|
e.target.value = '';
|
|
});
|
|
|
|
// ---- Upload from library ----
|
|
const uploadBtn = document.getElementById('upload-btn');
|
|
const uploadInput = document.getElementById('upload-input');
|
|
|
|
uploadBtn.addEventListener('click', function() {
|
|
uploadInput.click();
|
|
});
|
|
|
|
uploadInput.addEventListener('change', async function(e) {
|
|
const files = Array.from(e.target.files);
|
|
if (files.length === 0) return;
|
|
|
|
uploadBtn.disabled = true;
|
|
uploadBtn.textContent = '⏳ Processing 0/' + files.length + '...';
|
|
|
|
let saved = 0;
|
|
for (const file of files) {
|
|
try {
|
|
const jpegBlob = await convertToJPEG(file);
|
|
await savePhoto(jpegBlob);
|
|
saved++;
|
|
uploadBtn.textContent = '⏳ Processing ' + saved + '/' + files.length + '...';
|
|
} catch (err) {
|
|
console.error('Failed to process file:', file.name, err);
|
|
}
|
|
}
|
|
|
|
uploadBtn.textContent = '✓ ' + saved + ' photo' + (saved !== 1 ? 's' : '') + ' added';
|
|
loadRecentPhotos();
|
|
setTimeout(() => {
|
|
uploadBtn.disabled = false;
|
|
uploadBtn.textContent = '🖼️ Upload from Library';
|
|
}, 1500);
|
|
|
|
e.target.value = '';
|
|
});
|
|
|
|
// ---- Live camera viewfinder ----
|
|
async function openCamera() {
|
|
sessionCount = 0;
|
|
updateCameraCounter();
|
|
|
|
try {
|
|
cameraStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: facingMode, width: { ideal: 3840 }, height: { ideal: 2160 } },
|
|
audio: false
|
|
});
|
|
|
|
const video = document.getElementById('camera-video');
|
|
video.srcObject = cameraStream;
|
|
await video.play();
|
|
|
|
document.getElementById('camera-viewfinder').style.display = 'flex';
|
|
document.body.classList.add('camera-open');
|
|
} catch (err) {
|
|
console.error('getUserMedia failed:', err);
|
|
// Fallback to file input
|
|
cameraInput.click();
|
|
}
|
|
}
|
|
|
|
function closeCamera() {
|
|
if (cameraStream) {
|
|
cameraStream.getTracks().forEach(t => t.stop());
|
|
cameraStream = null;
|
|
}
|
|
|
|
const video = document.getElementById('camera-video');
|
|
video.srcObject = null;
|
|
|
|
document.getElementById('camera-viewfinder').style.display = 'none';
|
|
document.body.classList.remove('camera-open');
|
|
|
|
loadRecentPhotos();
|
|
}
|
|
|
|
async function flipCamera() {
|
|
facingMode = facingMode === 'environment' ? 'user' : 'environment';
|
|
|
|
if (cameraStream) {
|
|
cameraStream.getTracks().forEach(t => t.stop());
|
|
}
|
|
|
|
try {
|
|
cameraStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: facingMode, width: { ideal: 3840 }, height: { ideal: 2160 } },
|
|
audio: false
|
|
});
|
|
const video = document.getElementById('camera-video');
|
|
video.srcObject = cameraStream;
|
|
await video.play();
|
|
} catch (err) {
|
|
console.error('Flip camera failed:', err);
|
|
showCameraToast('Could not switch camera');
|
|
}
|
|
}
|
|
|
|
async function takePhoto() {
|
|
if (shutterBusy) return;
|
|
shutterBusy = true;
|
|
|
|
const shutterBtn = document.getElementById('camera-shutter-btn');
|
|
shutterBtn.disabled = true;
|
|
|
|
try {
|
|
const video = document.getElementById('camera-video');
|
|
const canvas = document.getElementById('camera-canvas');
|
|
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(video, 0, 0);
|
|
|
|
// Flash feedback
|
|
const flash = document.getElementById('camera-flash');
|
|
flash.classList.add('flash');
|
|
setTimeout(() => flash.classList.remove('flash'), 150);
|
|
|
|
const blob = await new Promise((resolve, reject) => {
|
|
canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas capture failed')), 'image/jpeg', 0.92);
|
|
});
|
|
|
|
await savePhoto(blob);
|
|
sessionCount++;
|
|
updateCameraCounter();
|
|
showCameraToast('Photo saved');
|
|
|
|
} catch (err) {
|
|
console.error('takePhoto error:', err);
|
|
showCameraToast('Error saving photo');
|
|
} finally {
|
|
shutterBusy = false;
|
|
shutterBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ---- Wire viewfinder buttons ----
|
|
document.getElementById('camera-close-btn').addEventListener('click', closeCamera);
|
|
document.getElementById('camera-shutter-btn').addEventListener('click', takePhoto);
|
|
document.getElementById('camera-flip-btn').addEventListener('click', flipCamera);
|
|
|
|
function updateCameraCounter() {
|
|
const el = document.getElementById('camera-counter');
|
|
el.textContent = sessionCount > 0 ? sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') : '';
|
|
}
|
|
|
|
function showCameraToast(msg) {
|
|
const toast = document.getElementById('camera-toast');
|
|
toast.textContent = msg;
|
|
toast.style.display = 'block';
|
|
setTimeout(() => { toast.style.display = 'none'; }, 1500);
|
|
}
|
|
|
|
// ---- Shared helpers ----
|
|
async function savePhoto(jpegBlob) {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
|
|
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) {
|
|
resolve(blob);
|
|
} else {
|
|
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');
|
|
</script>
|
|
{% endblock %}
|