T2: Photo gallery picker with preview
This commit is contained in:
+529
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user