@@ -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();