T1: Tesseract.js client-side OCR fallback
This commit is contained in:
+341
-28
@@ -5,12 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Canteen Asset Tracker</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
|
||||
<!-- Leaflet Map -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
DESIGN TOKENS
|
||||
@@ -264,6 +266,30 @@
|
||||
.status-bar.error { background: var(--red-bg); color: var(--red); }
|
||||
.status-bar.working { color: var(--accent2); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
OFFLINE BANNER
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.offline-banner {
|
||||
position: fixed; top: var(--header-height); left: 50%; transform: translateX(-50%);
|
||||
width: 100%; max-width: 480px; z-index: 199;
|
||||
background: var(--amber-bg); color: var(--amber);
|
||||
text-align: center; padding: 6px 12px; font-size: 13px; font-weight: 600;
|
||||
display: none; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.offline-banner.show { display: flex; }
|
||||
.offline-banner .queue-count {
|
||||
background: var(--amber); color: #000; font-size: 10px; font-weight: 700;
|
||||
padding: 1px 7px; border-radius: 10px; margin-left: 4px;
|
||||
}
|
||||
.queue-indicator {
|
||||
display: none; align-items: center; gap: 3px;
|
||||
background: var(--amber-bg); color: var(--amber);
|
||||
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 20px;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.queue-indicator.show { display: inline-flex; }
|
||||
.queue-indicator.syncing { background: var(--accent-bg); color: var(--accent2); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
TOAST
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
@@ -456,6 +482,49 @@
|
||||
.scan-result .sr-actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; }
|
||||
.scan-result .sr-checkin-done { font-size: 13px; color: #22c55e; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
PHOTO PICKER / GALLERY PREVIEW
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.photo-picker-btn {
|
||||
width: 100%; padding: 10px; border: 1px dashed var(--border2);
|
||||
background: var(--card); color: var(--text); font-size: 14px;
|
||||
font-weight: 600; cursor: pointer; border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
margin-bottom: 10px; transition: all 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.photo-picker-btn:active { background: var(--border); }
|
||||
.photo-preview-card {
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3); padding: 12px;
|
||||
margin-bottom: 10px; animation: fadeIn 0.2s;
|
||||
}
|
||||
.photo-preview-card img {
|
||||
width: 100%; border-radius: var(--radius-sm); margin-bottom: 10px;
|
||||
}
|
||||
.photo-preview-toolbar {
|
||||
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.photo-preview-toolbar button {
|
||||
flex: 1; min-width: 80px; padding: 8px 12px; border: 1px solid var(--border2);
|
||||
background: var(--card2); color: var(--text); font-size: 12px;
|
||||
font-weight: 600; border-radius: var(--radius-xs); cursor: pointer;
|
||||
transition: all 0.15s; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.photo-preview-toolbar button:active { background: var(--accent-bg); border-color: var(--accent); }
|
||||
.exif-data {
|
||||
background: var(--card2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs); padding: 10px; font-size: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
.exif-data .exif-row {
|
||||
display: flex; justify-content: space-between; padding: 3px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.exif-data .exif-row:last-child { border-bottom: none; }
|
||||
.exif-data .exif-label { color: var(--text3); }
|
||||
.exif-data .exif-value { color: var(--text); font-weight: 500; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
MODE TOGGLES (Add Asset tab)
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
@@ -542,6 +611,24 @@
|
||||
.ocr-result .or-meta { font-size: 12px; color: var(--text2); margin-top: 4px; }
|
||||
.ocr-result .or-raw { font-size: 11px; color: var(--text3); margin-top: 8px; padding: 8px; background: #0a0b0f; border-radius: 4px; max-height: 80px; overflow-y: auto; word-break: break-all; white-space: pre-wrap; }
|
||||
|
||||
/* OCR progress bar */
|
||||
.ocr-progress-wrap {
|
||||
margin-top: 6px; height: 4px; background: var(--border);
|
||||
border-radius: 2px; overflow: hidden; display: none;
|
||||
}
|
||||
.ocr-progress-wrap.active { display: block; }
|
||||
.ocr-progress-bar {
|
||||
height: 100%; width: 0%; background: var(--accent);
|
||||
border-radius: 2px; transition: width 0.3s;
|
||||
}
|
||||
/* Offline OCR badge */
|
||||
.offline-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 10px; font-weight: 700; padding: 2px 8px;
|
||||
border-radius: 10px; background: var(--amber-bg); color: var(--amber);
|
||||
margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
DETAIL VIEW (shared)
|
||||
@@ -1117,11 +1204,14 @@
|
||||
TAB: ADD ASSET (Barcode / OCR / Manual)
|
||||
═══════════════════════════════════════════════════════════════════════ -->
|
||||
<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)">
|
||||
<!-- Mode Toggles -->
|
||||
<div class="mode-toggles">
|
||||
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
|
||||
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
|
||||
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</button>
|
||||
<button class="photo-picker-btn" onclick="document.getElementById('photoPicker').click()">📱 Pick from Gallery</button>
|
||||
</div>
|
||||
|
||||
<!-- ── BARCODE MODE ──────────────────────────────────────────────── -->
|
||||
@@ -1183,6 +1273,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="ocrStatus" class="status-bar">Point camera at the machine sticker and tap capture</div>
|
||||
<div id="ocrProgressWrap" class="ocr-progress-wrap">
|
||||
<div id="ocrProgressBar" class="ocr-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OCR result -->
|
||||
<div id="ocrResult" class="ocr-result" style="display:none;"></div>
|
||||
@@ -1313,6 +1406,18 @@
|
||||
</div>
|
||||
</div> </div>
|
||||
|
||||
<!-- ── Photo Preview Card (shown when a photo is picked from gallery) ── -->
|
||||
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
|
||||
<img id="pickedPhotoPreview" src="" alt="Picked photo">
|
||||
<div class="photo-preview-toolbar">
|
||||
<button onclick="ocrPickedPhoto()">🔍 OCR this photo</button>
|
||||
<button onclick="extractGpsFromPicked()">📍 Extract GPS</button>
|
||||
<button onclick="createAssetFromPicked()">➕ Create Asset</button>
|
||||
</div>
|
||||
<div class="exif-data" id="pickedExifData" style="display:none;"></div>
|
||||
<button class="btn btn-outline btn-sm" style="margin-top:8px;width:100%;" onclick="clearPickedPhoto()">✕ Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
TAB: ASSETS LIST
|
||||
═══════════════════════════════════════════════════════════════════════ -->
|
||||
@@ -2494,6 +2599,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Photo Picker / Gallery ────────────────────────────────────────────────
|
||||
let pickedPhotoFile = null;
|
||||
|
||||
// Wire the hidden file input
|
||||
(function() {
|
||||
const pp = document.getElementById('photoPicker');
|
||||
if (pp) {
|
||||
pp.addEventListener('change', function(e) {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handlePickedPhoto(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function handlePickedPhoto(file) {
|
||||
pickedPhotoFile = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('pickedPhotoPreview').src = e.target.result;
|
||||
document.getElementById('photoPreviewCard').style.display = 'block';
|
||||
|
||||
// Extract EXIF / file metadata
|
||||
extractExifData(file);
|
||||
|
||||
// Scroll to preview
|
||||
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function extractExifData(file) {
|
||||
const exifDiv = document.getElementById('pickedExifData');
|
||||
const rows = [];
|
||||
rows.push({ label: 'File name', value: file.name });
|
||||
rows.push({ label: 'File size', value: formatFileSize(file.size) });
|
||||
rows.push({ label: 'Type', value: file.type || 'unknown' });
|
||||
rows.push({ label: 'Last modified', value: formatDate(new Date(file.lastModified).toISOString()) });
|
||||
|
||||
exifDiv.innerHTML = rows.map(r =>
|
||||
`<div class="exif-row"><span class="exif-label">${esc(r.label)}</span><span class="exif-value">${esc(r.value)}</span></div>`
|
||||
).join('');
|
||||
exifDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function clearPickedPhoto() {
|
||||
pickedPhotoFile = null;
|
||||
document.getElementById('photoPreviewCard').style.display = 'none';
|
||||
document.getElementById('pickedPhotoPreview').src = '';
|
||||
document.getElementById('pickedExifData').style.display = 'none';
|
||||
document.getElementById('pickedExifData').innerHTML = '';
|
||||
// Reset the file input so the same file can be picked again
|
||||
const pp = document.getElementById('photoPicker');
|
||||
if (pp) pp.value = '';
|
||||
}
|
||||
|
||||
function ocrPickedPhoto() {
|
||||
if (!pickedPhotoFile) return;
|
||||
// Switch to OCR mode with the picked photo available
|
||||
setAddAssetMode('ocr');
|
||||
showToast('Photo loaded in preview — OCR processing ready');
|
||||
}
|
||||
|
||||
function extractGpsFromPicked() {
|
||||
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');
|
||||
}
|
||||
|
||||
function createAssetFromPicked() {
|
||||
if (!pickedPhotoFile) return;
|
||||
// Switch to Manual mode and pre-load the photo
|
||||
setAddAssetMode('manual');
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
// Convert data URL to blob for manualPhotoBlob
|
||||
const arr = e.target.result.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)[1];
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) { u8arr[n] = bstr.charCodeAt(n); }
|
||||
manualPhotoBlob = new Blob([u8arr], { type: mime });
|
||||
// Show the photo in the manual preview
|
||||
const img = document.getElementById('manPhotoPreview');
|
||||
img.src = e.target.result;
|
||||
img.style.display = 'block';
|
||||
const ph = document.getElementById('manPhotoPlaceholder');
|
||||
if (ph) ph.style.display = 'none';
|
||||
const retake = document.getElementById('manPhotoRetake');
|
||||
if (retake) retake.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(pickedPhotoFile);
|
||||
showToast('Photo loaded into Manual mode');
|
||||
}
|
||||
|
||||
// ── Shared: Load settings dropdowns ─────────────────────────────────────
|
||||
async function loadSettingsCache() {
|
||||
if (settingsCache._loaded) return; // already loaded successfully
|
||||
@@ -2956,6 +3164,69 @@
|
||||
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
|
||||
}
|
||||
|
||||
function updateOcrProgress(m) {
|
||||
const wrap = document.getElementById('ocrProgressWrap');
|
||||
const bar = document.getElementById('ocrProgressBar');
|
||||
if (!wrap || !bar) return;
|
||||
if (m.status === 'recognizing text') {
|
||||
wrap.classList.add('active');
|
||||
const pct = Math.round((m.progress || 0) * 100);
|
||||
bar.style.width = pct + '%';
|
||||
}
|
||||
if (m.status === 'done' || m.status === 'error') {
|
||||
wrap.classList.remove('active');
|
||||
bar.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
function parseMachineId(text) {
|
||||
// Same logic as server-side: XXXXX-XXXXXX pattern → last 5 digits
|
||||
const match = text.match(/(\d{5})[-\s]*(\d{6,})/);
|
||||
if (match) {
|
||||
const digits = match[0].replace(/\D/g, '');
|
||||
return { machine_id: digits.slice(-5), confidence: 'high' };
|
||||
}
|
||||
// Looser: any 5+ digit number → last 5 digits
|
||||
const loose = text.match(/(\d{5,})/);
|
||||
if (loose) {
|
||||
const digits = loose[1];
|
||||
const mid = digits.length > 5 ? digits.slice(-5) : digits;
|
||||
return { machine_id: mid, confidence: 'low' };
|
||||
}
|
||||
return { machine_id: null, confidence: 'none' };
|
||||
}
|
||||
|
||||
async function ocrImageClient(imageBlob) {
|
||||
// Timeout race — 30s max for client-side Tesseract.js
|
||||
const ocrPromise = Tesseract.recognize(imageBlob, 'eng', {
|
||||
logger: m => updateOcrProgress(m),
|
||||
});
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Client OCR timed out after 30s')), 30000)
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await Promise.race([ocrPromise, timeoutPromise]);
|
||||
} catch (e) {
|
||||
updateOcrProgress({ status: 'error' });
|
||||
throw new Error('Client OCR failed: ' + (e.message || 'unknown error'));
|
||||
}
|
||||
|
||||
updateOcrProgress({ status: 'done' });
|
||||
|
||||
const text = (result.data && result.data.text) || '';
|
||||
const parsed = parseMachineId(text.trim());
|
||||
|
||||
return {
|
||||
machine_id: parsed.machine_id,
|
||||
raw_text: text.trim().slice(0, 500),
|
||||
confidence: parsed.confidence,
|
||||
method: 'client',
|
||||
tesseractConfidence: result.data ? result.data.confidence : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureOcr() {
|
||||
const video = document.getElementById('ocrVideo');
|
||||
if (!video || !ocrStream) return;
|
||||
@@ -2970,39 +3241,81 @@
|
||||
document.getElementById('ocrResult').style.display = 'none';
|
||||
document.getElementById('ocrCreateCard').style.display = 'none';
|
||||
|
||||
let data = null;
|
||||
let method = 'server';
|
||||
|
||||
// Try server-side OCR first with 8s timeout
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const serverTimeout = setTimeout(() => controller.abort(), 8000);
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'sticker.jpg');
|
||||
const data = await api('/api/ocr', { method: 'POST', body: formData });
|
||||
|
||||
const el = document.getElementById('ocrResult');
|
||||
el.style.display = 'block';
|
||||
|
||||
if (data.machine_id) {
|
||||
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
|
||||
data.confidence === 'low' ? '⚠️ Low confidence' : '';
|
||||
el.innerHTML = `
|
||||
<div class="or-id">${esc(data.machine_id)}</div>
|
||||
<div class="or-meta">${confLabel}</div>
|
||||
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
|
||||
setOcrStatus('Machine ID found!', 'success');
|
||||
|
||||
// Show quick create form
|
||||
document.getElementById('ocrMachineId').value = data.machine_id;
|
||||
document.getElementById('ocrName').value = '';
|
||||
populateCategorySelect('ocrCatSelect');
|
||||
document.getElementById('ocrStatus').value = 'active';
|
||||
document.getElementById('ocrCreateCard').style.display = 'block';
|
||||
document.getElementById('ocrName').focus();
|
||||
const res = await fetch('/api/ocr', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
headers: AppState.authToken
|
||||
? { 'Authorization': 'Bearer ' + AppState.authToken }
|
||||
: {},
|
||||
});
|
||||
clearTimeout(serverTimeout);
|
||||
if (res.ok) {
|
||||
data = await res.json();
|
||||
data.method = 'server';
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="font-size:16px;font-weight:600;color:var(--red);">No machine ID detected</div>
|
||||
<div class="or-meta">Try again with better lighting and focus on the sticker.</div>
|
||||
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
|
||||
setOcrStatus('No machine ID found — try again', 'error');
|
||||
throw new Error('Server returned ' + res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
setOcrStatus('OCR failed: ' + e.message, 'error');
|
||||
} catch (serverErr) {
|
||||
// Server failed or timed out — fall back to client-side Tesseract.js
|
||||
setOcrStatus('Server unavailable — using offline OCR...', 'working');
|
||||
// Show offline badge in status
|
||||
const statusEl = document.getElementById('ocrStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = 'Server unavailable — <span class="offline-badge">📡 Offline OCR</span>';
|
||||
statusEl.className = 'status-bar working';
|
||||
}
|
||||
try {
|
||||
data = await ocrImageClient(blob);
|
||||
method = 'client';
|
||||
} catch (clientErr) {
|
||||
setOcrStatus('OCR failed: ' + clientErr.message, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset status bar innerHTML from offline badge if server succeeded
|
||||
if (method === 'server') {
|
||||
setOcrStatus('Processing OCR...', 'working');
|
||||
}
|
||||
|
||||
const el = document.getElementById('ocrResult');
|
||||
el.style.display = 'block';
|
||||
|
||||
if (data.machine_id) {
|
||||
const methodBadge = data.method === 'client'
|
||||
? '<span class="offline-badge">📡 Offline OCR</span>'
|
||||
: '';
|
||||
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
|
||||
data.confidence === 'low' ? '⚠️ Low confidence' : '';
|
||||
el.innerHTML = `
|
||||
<div class="or-id">${esc(data.machine_id)}${methodBadge}</div>
|
||||
<div class="or-meta">${confLabel}</div>
|
||||
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
|
||||
setOcrStatus('Machine ID found!', 'success');
|
||||
|
||||
// Show quick create form
|
||||
document.getElementById('ocrMachineId').value = data.machine_id;
|
||||
document.getElementById('ocrName').value = '';
|
||||
populateCategorySelect('ocrCatSelect');
|
||||
document.getElementById('ocrStatus').value = 'active';
|
||||
document.getElementById('ocrCreateCard').style.display = 'block';
|
||||
document.getElementById('ocrName').focus();
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="font-size:16px;font-weight:600;color:var(--red);">No machine ID detected</div>
|
||||
<div class="or-meta">Try again with better lighting and focus on the sticker.</div>
|
||||
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
|
||||
setOcrStatus('No machine ID found — try again', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Canteen Asset Tracker — Service Worker
|
||||
// Caches app shell + CDN deps. Network-first for API, cache fallback for static.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const CACHE_NAME = 'canteen-v1';
|
||||
|
||||
// App shell — core resources needed to boot the PWA
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
];
|
||||
|
||||
// CDN resources we want cached for offline resilience
|
||||
const CDN_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js',
|
||||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
|
||||
'https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css',
|
||||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
|
||||
'https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js',
|
||||
'https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js',
|
||||
];
|
||||
|
||||
// ── Install: pre-cache app shell + CDN resources ──────────────────────────
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Install — caching app shell + CDN assets');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll([...APP_SHELL, ...CDN_URLS]).catch((err) => {
|
||||
console.warn('[SW] Some resources failed to cache (non-fatal):', err.message);
|
||||
});
|
||||
})
|
||||
);
|
||||
// Activate immediately — don't wait for old SW to release
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// ── Activate: purge old caches ────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activate — purging old caches');
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
);
|
||||
})
|
||||
);
|
||||
// Take control of all clients immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// ── Fetch: network-first for everything, cache fallback ───────────────────
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Navigation requests: network-first, fallback to cached index.html
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Cache the latest version
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request).then((cached) => cached || caches.match('/'));
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// API calls: pass through — main thread handles offline queuing
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets (local + CDN): network-first, cache fallback
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ── Message handler: allow main thread to trigger queue sync ──────────────
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SYNC_QUEUE') {
|
||||
// Forward sync request to all controlled clients
|
||||
self.clients.matchAll().then((clients) => {
|
||||
clients.forEach((client) => {
|
||||
client.postMessage({ type: 'PROCESS_OFFLINE_QUEUE' });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user