T2: Photo gallery picker with preview
This commit is contained in:
+529
-5
@@ -1144,6 +1144,7 @@
|
|||||||
<div class="header-badges">
|
<div class="header-badges">
|
||||||
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
|
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
|
||||||
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...</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 class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1205,7 +1206,7 @@
|
|||||||
═══════════════════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════════════════ -->
|
||||||
<div id="tabAddAsset" class="tab-panel active">
|
<div id="tabAddAsset" class="tab-panel active">
|
||||||
<!-- Hidden photo picker input -->
|
<!-- 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 -->
|
<!-- Mode Toggles -->
|
||||||
<div class="mode-toggles">
|
<div class="mode-toggles">
|
||||||
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
|
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
|
||||||
@@ -2601,6 +2602,7 @@
|
|||||||
|
|
||||||
// ── Photo Picker / Gallery ────────────────────────────────────────────────
|
// ── Photo Picker / Gallery ────────────────────────────────────────────────
|
||||||
let pickedPhotoFile = null;
|
let pickedPhotoFile = null;
|
||||||
|
let pickedPhotoGps = null; // { lat, lng, accuracy } from EXIF
|
||||||
|
|
||||||
// Wire the hidden file input
|
// Wire the hidden file input
|
||||||
(function() {
|
(function() {
|
||||||
@@ -2616,6 +2618,7 @@
|
|||||||
|
|
||||||
function handlePickedPhoto(file) {
|
function handlePickedPhoto(file) {
|
||||||
pickedPhotoFile = file;
|
pickedPhotoFile = file;
|
||||||
|
pickedPhotoGps = null;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function(e) {
|
reader.onload = function(e) {
|
||||||
document.getElementById('pickedPhotoPreview').src = e.target.result;
|
document.getElementById('pickedPhotoPreview').src = e.target.result;
|
||||||
@@ -2624,6 +2627,9 @@
|
|||||||
// Extract EXIF / file metadata
|
// Extract EXIF / file metadata
|
||||||
extractExifData(file);
|
extractExifData(file);
|
||||||
|
|
||||||
|
// Try EXIF GPS extraction
|
||||||
|
tryExtractGpsFromPicked();
|
||||||
|
|
||||||
// Scroll to preview
|
// Scroll to preview
|
||||||
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
@@ -2652,6 +2658,7 @@
|
|||||||
|
|
||||||
function clearPickedPhoto() {
|
function clearPickedPhoto() {
|
||||||
pickedPhotoFile = null;
|
pickedPhotoFile = null;
|
||||||
|
pickedPhotoGps = null;
|
||||||
document.getElementById('photoPreviewCard').style.display = 'none';
|
document.getElementById('photoPreviewCard').style.display = 'none';
|
||||||
document.getElementById('pickedPhotoPreview').src = '';
|
document.getElementById('pickedPhotoPreview').src = '';
|
||||||
document.getElementById('pickedExifData').style.display = 'none';
|
document.getElementById('pickedExifData').style.display = 'none';
|
||||||
@@ -2668,11 +2675,149 @@
|
|||||||
showToast('Photo loaded in preview — OCR processing ready');
|
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;
|
if (!pickedPhotoFile) return;
|
||||||
// Browser FileReader doesn't expose EXIF GPS without a library.
|
const exifEl = document.getElementById('pickedExifData');
|
||||||
// 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');
|
// 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() {
|
function createAssetFromPicked() {
|
||||||
@@ -3037,6 +3182,12 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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!');
|
showToast('Asset created!');
|
||||||
document.getElementById('newAssetCard').style.display = 'none';
|
document.getElementById('newAssetCard').style.display = 'none';
|
||||||
AppState.currentAssetId = asset.id;
|
AppState.currentAssetId = asset.id;
|
||||||
@@ -3076,6 +3227,7 @@
|
|||||||
|
|
||||||
// ── Auto check-in on asset creation (GPS grab) ──────────────────────────
|
// ── Auto check-in on asset creation (GPS grab) ──────────────────────────
|
||||||
async function autoCheckin(assetId) {
|
async function autoCheckin(assetId) {
|
||||||
|
if (!assetId) return; // Skip if asset was queued offline
|
||||||
if (!AppState.gpsLat) return; // No GPS — skip silently
|
if (!AppState.gpsLat) return; // No GPS — skip silently
|
||||||
try {
|
try {
|
||||||
await api('/api/checkins', {
|
await api('/api/checkins', {
|
||||||
@@ -3347,6 +3499,13 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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!');
|
showToast('Asset created!');
|
||||||
document.getElementById('ocrCreateCard').style.display = 'none';
|
document.getElementById('ocrCreateCard').style.display = 'none';
|
||||||
document.getElementById('ocrResult').style.display = 'none';
|
document.getElementById('ocrResult').style.display = 'none';
|
||||||
@@ -3490,6 +3649,29 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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!');
|
showToast('Asset created!');
|
||||||
AppState.currentAssetId = asset.id;
|
AppState.currentAssetId = asset.id;
|
||||||
autoCheckin(asset.id);
|
autoCheckin(asset.id);
|
||||||
@@ -6581,9 +6763,351 @@
|
|||||||
registerAction('saveEditUser', (el) => { saveEditUser(parseInt(el.dataset.id)); });
|
registerAction('saveEditUser', (el) => { saveEditUser(parseInt(el.dataset.id)); });
|
||||||
registerAction('resetDatabase', () => { resetDatabase(); });
|
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
|
// INIT
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
initOfflineSupport();
|
||||||
initAuth();
|
initAuth();
|
||||||
initGPS();
|
initGPS();
|
||||||
startScanning();
|
startScanning();
|
||||||
|
|||||||
Reference in New Issue
Block a user