The previous approach used setTimeout to re-trigger the file input after each capture, which iOS Safari blocks because it loses the user gesture context. Now uses getUserMedia for a fullscreen camera viewfinder that stays open between shots. Shutter button captures frames from the video stream, saves to IndexedDB, and the camera remains open until the user taps close. Includes flash feedback, session counter, and camera flip. Falls back to single-shot file input if getUserMedia is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
584 lines
15 KiB
HTML
584 lines
15 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>
|
|
|
|
<!-- Hidden file input as fallback -->
|
|
<input
|
|
type="file"
|
|
id="camera-input"
|
|
accept="image/*"
|
|
capture="environment"
|
|
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;
|
|
}
|
|
|
|
.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 = '';
|
|
});
|
|
|
|
// ---- 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 %}
|