T2: Photo gallery picker with preview

This commit is contained in:
2026-05-21 11:20:20 -04:00
parent 5cb8e8aa73
commit f895bf99de
+529 -5
View File
@@ -1144,6 +1144,7 @@
<div class="header-badges">
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...</span>
<span id="queueIndicator" class="queue-indicator" onclick="processOfflineQueue()" title="Offline queue">⬇️ <span id="queueIndicatorCount">0</span></span>
<div class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</div>
</div>
</div>
@@ -1205,7 +1206,7 @@
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabAddAsset" class="tab-panel active">
<!-- Hidden photo picker input -->
<input type="file" id="photoPicker" accept="image/*" capture="environment" style="display:none;" onchange="handlePickedPhoto(event)">
<input type="file" id="photoPicker" accept="image/*" capture="environment" style="display:none;">
<!-- Mode Toggles -->
<div class="mode-toggles">
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
@@ -2601,6 +2602,7 @@
// ── Photo Picker / Gallery ────────────────────────────────────────────────
let pickedPhotoFile = null;
let pickedPhotoGps = null; // { lat, lng, accuracy } from EXIF
// Wire the hidden file input
(function() {
@@ -2616,6 +2618,7 @@
function handlePickedPhoto(file) {
pickedPhotoFile = file;
pickedPhotoGps = null;
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('pickedPhotoPreview').src = e.target.result;
@@ -2624,6 +2627,9 @@
// Extract EXIF / file metadata
extractExifData(file);
// Try EXIF GPS extraction
tryExtractGpsFromPicked();
// Scroll to preview
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
};
@@ -2652,6 +2658,7 @@
function clearPickedPhoto() {
pickedPhotoFile = null;
pickedPhotoGps = null;
document.getElementById('photoPreviewCard').style.display = 'none';
document.getElementById('pickedPhotoPreview').src = '';
document.getElementById('pickedExifData').style.display = 'none';
@@ -2668,11 +2675,149 @@
showToast('Photo loaded in preview — OCR processing ready');
}
function extractGpsFromPicked() {
// ▶ Core EXIF GPS extraction using exifr.js (loaded in <head>)
async function extractGpsFromPhoto(file) {
try {
if (typeof exifr === 'undefined') {
console.warn('exifr not loaded');
return null;
}
const gps = await exifr.gps(file);
if (gps && gps.latitude != null && gps.longitude != null) {
return {
lat: gps.latitude,
lng: gps.longitude,
accuracy: gps.accuracy || null,
};
}
return null;
} catch (e) {
console.warn('EXIF GPS extraction failed:', e.message);
return null;
}
}
// ▶ Try to extract GPS from the currently picked photo and render results
async function tryExtractGpsFromPicked() {
if (!pickedPhotoFile) return;
// Browser FileReader doesn't expose EXIF GPS without a library.
// For now, show what we have and note server-side parsing is available.
showToast('GPS data will be extracted server-side — check EXIF info below for file details');
const exifEl = document.getElementById('pickedExifData');
// Append GPS status to existing EXIF display
let gpsSection = document.getElementById('pickedGpsSection');
if (!gpsSection) {
gpsSection = document.createElement('div');
gpsSection.id = 'pickedGpsSection';
gpsSection.style.marginTop = '8px';
gpsSection.style.paddingTop = '8px';
gpsSection.style.borderTop = '1px solid var(--border)';
exifEl.appendChild(gpsSection);
}
gpsSection.innerHTML = '<span style="color:var(--amber);">⏳ Reading GPS from photo...</span>';
const gps = await extractGpsFromPhoto(pickedPhotoFile);
pickedPhotoGps = gps;
if (gps) {
gpsSection.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span class="gps-badge ok">📍 GPS from photo</span>
</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:4px;">
${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)}
${gps.accuracy ? ` (±${gps.accuracy.toFixed(1)}m)` : ''}
</div>
<div id="gpsMiniMap"></div>
<div style="display:flex;gap:6px;margin-top:8px;">
<button class="btn btn-sm btn-outline" onclick="fillManualFromGps()" style="flex:1;font-size:12px;">
📝 Fill Form
</button>
<button class="btn btn-sm btn-outline" onclick="openInMaps()" style="flex:1;font-size:12px;">
🗺️ Maps
</button>
</div>
`;
// Render mini-map
const miniMapEl = document.getElementById('gpsMiniMap');
if (miniMapEl) {
setTimeout(() => renderGpsPreview(gps.lat, gps.lng, miniMapEl), 100);
}
} else {
gpsSection.innerHTML = `
<span class="gps-badge err">📍 No GPS in photo</span>
<div style="font-size:11px;color:var(--text3);margin-top:4px;">
Phones often strip EXIF GPS when sharing. You can enter coordinates manually.
</div>
`;
}
}
// ▶ Render a mini-map preview using Leaflet
function renderGpsPreview(lat, lng, containerEl) {
if (!containerEl) return;
containerEl.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.style.width = '100%';
wrapper.style.height = '160px';
wrapper.style.borderRadius = 'var(--radius-sm)';
wrapper.style.overflow = 'hidden';
containerEl.appendChild(wrapper);
const map = L.map(wrapper, {
center: [lat, lng],
zoom: 15,
zoomControl: false,
attributionControl: false,
dragging: false,
scrollWheelZoom: false,
touchZoom: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
L.marker([lat, lng]).addTo(map)
.bindPopup(`${lat.toFixed(5)}, ${lng.toFixed(5)}`);
setTimeout(() => map.invalidateSize(), 300);
}
// ▶ Called when "📍 Extract GPS" button is clicked in photo preview toolbar
async function extractGpsFromPicked() {
if (!pickedPhotoFile) {
showToast('No photo picked', true);
return;
}
await tryExtractGpsFromPicked();
}
// ▶ Fill the manual form fields with extracted GPS coordinates
function fillManualFromGps() {
if (!pickedPhotoGps) {
showToast('No GPS data to fill', true);
return;
}
const coords = pickedPhotoGps.lat.toFixed(6) + ', ' + pickedPhotoGps.lng.toFixed(6);
// Switch to manual mode
setAddAssetMode('manual');
const parkingEl = document.getElementById('manParkingLocation');
if (parkingEl && !parkingEl.value.trim()) {
parkingEl.value = coords;
}
const mapLinkEl = document.getElementById('manMapLink');
if (mapLinkEl && !mapLinkEl.value.trim()) {
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${pickedPhotoGps.lat}&mlon=${pickedPhotoGps.lng}&zoom=17`;
}
showToast('📍 GPS coordinates filled! Edit if needed.');
}
// ▶ Open coordinates in Google Maps
function openInMaps() {
if (!pickedPhotoGps) return;
window.open(`https://www.google.com/maps?q=${pickedPhotoGps.lat},${pickedPhotoGps.lng}`, '_blank');
}
function createAssetFromPicked() {
@@ -3037,6 +3182,12 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (asset._queued) {
// Queued for offline sync — skip checkin and detail view
showToast('📴 Queued offline — will sync when connected');
document.getElementById('newAssetCard').style.display = 'none';
return;
}
showToast('Asset created!');
document.getElementById('newAssetCard').style.display = 'none';
AppState.currentAssetId = asset.id;
@@ -3076,6 +3227,7 @@
// ── Auto check-in on asset creation (GPS grab) ──────────────────────────
async function autoCheckin(assetId) {
if (!assetId) return; // Skip if asset was queued offline
if (!AppState.gpsLat) return; // No GPS — skip silently
try {
await api('/api/checkins', {
@@ -3347,6 +3499,13 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (asset._queued) {
showToast('📴 Queued offline — will sync when connected');
document.getElementById('ocrCreateCard').style.display = 'none';
document.getElementById('ocrResult').style.display = 'none';
setOcrStatus('Ready — take another photo', 'success');
return;
}
showToast('Asset created!');
document.getElementById('ocrCreateCard').style.display = 'none';
document.getElementById('ocrResult').style.display = 'none';
@@ -3490,6 +3649,29 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (asset._queued) {
showToast('📴 Queued offline — will sync when connected');
manualPhotoBlob = null;
if (addAnother) {
// Clear form for next entry
document.getElementById('manMachineId').value = '';
document.getElementById('manName').value = '';
document.getElementById('manDescription').value = '';
document.getElementById('manCatSelect').value = '';
document.getElementById('manMake').value = '';
document.getElementById('manModel').value = '';
document.getElementById('manStatus').value = 'active';
document.getElementById('manAddress').value = '';
document.getElementById('manBuildingName').value = '';
document.getElementById('manBuildingNumber').value = '';
document.getElementById('manFloor').value = '';
document.getElementById('manRoom').value = '';
document.getElementById('manPhotoPreview').style.display = 'none';
document.getElementById('manPhotoRetake').style.display = 'none';
document.getElementById('manMachineId').focus();
}
return;
}
showToast('Asset created!');
AppState.currentAssetId = asset.id;
autoCheckin(asset.id);
@@ -6581,9 +6763,351 @@
registerAction('saveEditUser', (el) => { saveEditUser(parseInt(el.dataset.id)); });
registerAction('resetDatabase', () => { resetDatabase(); });
// ═══════════════════════════════════════════════════════════════════════
// OFFLINE QUEUE — IndexedDB-backed queue for asset creation while offline
// ═══════════════════════════════════════════════════════════════════════
const OFFLINE_DB_NAME = 'CanteenOfflineDB';
const OFFLINE_DB_VERSION = 1;
const OFFLINE_STORE = 'offlineQueue';
let offlineDB = null;
function openOfflineDB() {
if (offlineDB) return Promise.resolve(offlineDB);
return new Promise((resolve, reject) => {
const req = indexedDB.open(OFFLINE_DB_NAME, OFFLINE_DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(OFFLINE_STORE)) {
const store = db.createObjectStore(OFFLINE_STORE, { keyPath: 'id', autoIncrement: true });
store.createIndex('status', 'status', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
req.onsuccess = (e) => { offlineDB = e.target.result; resolve(offlineDB); };
req.onerror = (e) => { reject(e.target.error); };
});
}
async function queueOfflineCreate(assetPayload, photoBlob) {
await openOfflineDB();
const entry = {
url: '/api/assets',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assetPayload),
photoBlob: photoBlob || null,
checkinGps: AppState.gpsLat ? { lat: AppState.gpsLat, lng: AppState.gpsLng, acc: AppState.gpsAcc } : null,
createdAt: new Date().toISOString(),
status: 'pending',
error: null,
retries: 0,
};
return new Promise((resolve, reject) => {
const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite');
const store = tx.objectStore(OFFLINE_STORE);
const req = store.add(entry);
req.onsuccess = () => { updateQueueUI(); resolve(req.result); };
req.onerror = () => reject(req.error);
});
}
async function getQueueCount() {
try {
await openOfflineDB();
return new Promise((resolve) => {
const tx = offlineDB.transaction(OFFLINE_STORE, 'readonly');
const store = tx.objectStore(OFFLINE_STORE);
const idx = store.index('status');
const req = idx.count('pending');
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(0);
});
} catch (e) { return 0; }
}
async function updateQueueUI() {
const count = await getQueueCount();
const qi = document.getElementById('queueIndicator');
const qc = document.getElementById('queueIndicatorCount');
const qb = document.getElementById('queueCountBadge');
if (count > 0) {
qi.classList.add('show');
qc.textContent = count;
if (qb) { qb.style.display = 'inline'; qb.textContent = count; }
} else {
qi.classList.remove('show');
if (qb) qb.style.display = 'none';
}
}
async function uploadQueuedPhoto(blob) {
const fd = new FormData();
fd.append('file', blob, 'photo.jpg');
const upData = await api('/api/upload/photo', { method: 'POST', body: fd });
return upData.path;
}
async function processQueueItem(item) {
await openOfflineDB();
await new Promise((resolve) => {
const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite');
const store = tx.objectStore(OFFLINE_STORE);
const req = store.get(item.id);
req.onsuccess = () => {
const record = req.result;
if (record) { record.status = 'syncing'; store.put(record); }
resolve();
};
req.onerror = () => resolve();
});
try {
let photoPath = null;
if (item.photoBlob) {
try { photoPath = await uploadQueuedPhoto(item.photoBlob); }
catch (e) { console.warn('Queue: photo upload failed, continuing:', e.message); }
}
const payload = JSON.parse(item.body);
if (photoPath) payload.photo_path = photoPath;
if (item.checkinGps) {
payload.latitude = item.checkinGps.lat;
payload.longitude = item.checkinGps.lng;
}
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (item.checkinGps && asset && asset.id) {
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: asset.id,
latitude: item.checkinGps.lat,
longitude: item.checkinGps.lng,
accuracy: item.checkinGps.acc,
notes: 'Auto check-in on sync',
}),
});
} catch (e) { /* best-effort */ }
}
await openOfflineDB();
await new Promise((resolve) => {
const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite');
const store = tx.objectStore(OFFLINE_STORE);
const req = store.get(item.id);
req.onsuccess = () => {
const record = req.result;
if (record) { record.status = 'done'; store.put(record); }
resolve();
};
req.onerror = () => resolve();
});
return true;
} catch (e) {
const isConflict = e.message && (
e.message.toLowerCase().includes('duplicate') ||
e.message.toLowerCase().includes('unique') ||
e.message.toLowerCase().includes('already exists') ||
e.message.toLowerCase().includes('conflict')
);
await openOfflineDB();
await new Promise((resolve) => {
const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite');
const store = tx.objectStore(OFFLINE_STORE);
const req = store.get(item.id);
req.onsuccess = () => {
const record = req.result;
if (record) {
record.status = isConflict ? 'done' : 'failed';
record.error = e.message;
if (!isConflict) record.retries = (record.retries || 0) + 1;
store.put(record);
}
resolve();
};
req.onerror = () => resolve();
});
return isConflict;
}
}
async function processOfflineQueue() {
if (!navigator.onLine) {
showToast('Still offline — will sync when connected', true);
return;
}
await openOfflineDB();
const qi = document.getElementById('queueIndicator');
qi.classList.add('syncing');
const pendingItems = await new Promise((resolve) => {
const tx = offlineDB.transaction(OFFLINE_STORE, 'readonly');
const store = tx.objectStore(OFFLINE_STORE);
const req = store.getAll();
req.onsuccess = () => {
resolve((req.result || []).filter(i => i.status === 'pending' || i.status === 'failed'));
};
req.onerror = () => resolve([]);
});
if (pendingItems.length === 0) {
qi.classList.remove('syncing');
updateQueueUI();
return;
}
showToast('Syncing ' + pendingItems.length + ' offline item(s)...');
let synced = 0;
for (const item of pendingItems) {
if (item.retries >= 5) { continue; }
const ok = await processQueueItem(item);
if (ok) synced++;
}
qi.classList.remove('syncing');
updateQueueUI();
if (synced > 0) {
showToast('Synced ' + synced + ' item(s)!');
if (AppState.currentTab === 'tabAssets') loadAssets();
} else if (pendingItems.length > synced) {
showToast((pendingItems.length - synced) + ' item(s) failed to sync', true);
}
}
function showOfflineBanner() {
document.getElementById('offlineBanner').classList.add('show');
}
function hideOfflineBanner() {
document.getElementById('offlineBanner').classList.remove('show');
processOfflineQueue();
}
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker not supported');
return;
}
try {
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
console.log('[SW] Registered, scope:', reg.scope);
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'PROCESS_OFFLINE_QUEUE') {
processOfflineQueue();
}
});
} catch (e) {
console.warn('[SW] Registration failed:', e.message);
}
}
const _originalApi = api;
window.api = async function apiWithOffline(url, opts) {
if (!opts) opts = {};
const method = (opts.method || 'GET').toUpperCase();
const isWrite = ['POST', 'PUT', 'DELETE'].includes(method);
const isAssetCreate = url === '/api/assets' && method === 'POST';
if (!isWrite || navigator.onLine) {
try {
return await _originalApi(url, opts);
} catch (e) {
if (isAssetCreate && (e.message === 'Network error — check your connection' || e.name === 'TypeError')) {
return await _handleOfflineAssetCreate(opts);
}
throw e;
}
}
if (isAssetCreate) {
return await _handleOfflineAssetCreate(opts);
}
if (isWrite) {
showToast('Offline — changes to assets will sync when connected', true);
throw new Error('Offline — unable to save.');
}
throw new Error('Offline');
};
async function _handleOfflineAssetCreate(opts) {
let payload, photoBlob = null;
if (typeof opts.body === 'string') {
try { payload = JSON.parse(opts.body); } catch (e) { payload = {}; }
} else if (opts.body instanceof FormData) {
payload = {};
} else {
payload = opts.body || {};
}
if (typeof manualPhotoBlob !== 'undefined' && manualPhotoBlob) {
photoBlob = manualPhotoBlob;
manualPhotoBlob = null;
}
await queueOfflineCreate(payload, photoBlob);
showToast('📴 Queued offline — will sync when connected');
showOfflineBanner();
return { id: null, _queued: true, machine_id: payload.machine_id, name: payload.name };
}
async function initOfflineSupport() {
await registerServiceWorker();
await openOfflineDB();
await updateQueueUI();
window.addEventListener('online', () => { hideOfflineBanner(); });
window.addEventListener('offline', () => { showOfflineBanner(); });
if (!navigator.onLine) { showOfflineBanner(); }
}
// ═══════════════════════════════════════════════════════════════════════
// T3: Hook EXIF GPS extraction into camera capture
// ═══════════════════════════════════════════════════════════════════════
(function() {
const _origCapture = captureManualPhoto;
captureManualPhoto = async function() {
await _origCapture();
if (manualPhotoBlob) {
try {
const gps = await extractGpsFromPhoto(manualPhotoBlob);
if (gps) {
const coords = gps.lat.toFixed(6) + ', ' + gps.lng.toFixed(6);
const parkingEl = document.getElementById('manParkingLocation');
if (parkingEl && !parkingEl.value.trim()) {
parkingEl.value = coords;
}
const mapLinkEl = document.getElementById('manMapLink');
if (mapLinkEl && !mapLinkEl.value.trim()) {
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${gps.lat}&mlon=${gps.lng}&zoom=17`;
}
let gpsTag = document.getElementById('manPhotoGpsTag');
if (!gpsTag) {
gpsTag = document.createElement('span');
gpsTag.id = 'manPhotoGpsTag';
gpsTag.className = 'gps-badge ok';
gpsTag.style.marginLeft = '8px';
gpsTag.textContent = '📍 GPS from photo';
const title = document.querySelector('#addManualMode .form-section-title');
if (title) title.appendChild(gpsTag);
}
gpsTag.style.display = 'inline-flex';
showToast('📍 GPS extracted from photo!');
}
} catch (e) { /* GPS extraction is non-critical */ }
}
};
const _origRetake = retakeManualPhoto;
retakeManualPhoto = function() {
_origRetake();
const gpsTag = document.getElementById('manPhotoGpsTag');
if (gpsTag) gpsTag.style.display = 'none';
};
})();
// ═══════════════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════════════
initOfflineSupport();
initAuth();
initGPS();
startScanning();