diff --git a/static/index.html b/static/index.html index caa5f96..fec192d 100644 --- a/static/index.html +++ b/static/index.html @@ -1144,6 +1144,7 @@
πŸ“ GPS... + ⬇️ 0
?
@@ -1205,7 +1206,7 @@ ═══════════════════════════════════════════════════════════════════════ -->
- +
@@ -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 ) + 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 = '⏳ Reading GPS from photo...'; + + const gps = await extractGpsFromPhoto(pickedPhotoFile); + pickedPhotoGps = gps; + + if (gps) { + gpsSection.innerHTML = ` +
+ πŸ“ GPS from photo +
+
+ ${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)} + ${gps.accuracy ? ` (Β±${gps.accuracy.toFixed(1)}m)` : ''} +
+
+
+ + +
+ `; + // Render mini-map + const miniMapEl = document.getElementById('gpsMiniMap'); + if (miniMapEl) { + setTimeout(() => renderGpsPreview(gps.lat, gps.lng, miniMapEl), 100); + } + } else { + gpsSection.innerHTML = ` + πŸ“ No GPS in photo +
+ Phones often strip EXIF GPS when sharing. You can enter coordinates manually. +
+ `; + } + } + + // β–Ά 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();