diff --git a/app.js b/app.js index 28445ee..45fc003 100644 --- a/app.js +++ b/app.js @@ -933,21 +933,52 @@ router.on('/orders/:id', async (args) => { } }); +/* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität). + * Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal + * zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt. + */ +const PHOTO_CFG_KEY = 'baustelle.photo_cfg'; +let photoCfg = (function () { + try { return JSON.parse(localStorage.getItem(PHOTO_CFG_KEY)) || null; } catch (_) { return null; } +})() || { photo_maxside: 0, photo_quality: 100 }; + +async function refreshPhotoConfig() { + try { + const c = await api.getConfig(); + if (c && typeof c.photo_maxside === 'number') { + photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100 }; + localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg)); + } + } catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ } +} + async function uploadPhoto(orderId, file) { - // Persist-First gegen Datenverlust: Foto ZUERST verkleinern und SOFORT persistent - // in die IndexedDB-Queue schreiben — BEVOR ein Upload versucht wird. Danach senden. - // So geht kein Foto verloren, egal ob Netz da ist, die Verbindung hängt oder die - // App mitten im Upload geschlossen wird. (syncQueue löscht nur bei bestätigtem Erfolg.) - let blob = file; - try { blob = await resizeImage(file, 2000); } catch (e) { blob = file; } + // Persist-First, streng: das Foto geht UNVERAENDERT und als ALLERERSTES in die + // Queue. Frueher wurde davor verkleinert — und weil das Verkleinern bei + // ausgeschaltetem Display haengen bleibt (pausiertes Decoding), war das Foto in + // dem Moment nirgends gesichert und die ganze Auswahl-Schleife stand. Genau so + // gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts + // mehr passieren, was das Foto kostet. const name = file.name || ('foto_' + Date.now() + '.jpg'); - await offline.enqueuePhoto(orderId, blob, name); + const qid = await offline.enqueuePhoto(orderId, file, name); + + // Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es + // schiefgeht oder haengt, bleibt schlicht das Original in der Queue. + if (photoCfg.photo_maxside > 0) { + try { + const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100); + if (small && small !== file && small.size < file.size) { + await offline.replaceQueuedBlob(qid, small); + } + } catch (_) { /* Original bleibt gesichert */ } + } + if (navigator.onLine) showToast('Sende ' + name + '…'); else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn'); await offline.syncQueue(); } -async function resizeImage(file, maxSide, timeoutMs) { +async function resizeImage(file, maxSide, timeoutMs, quality) { // SCHUTZZEIT (v1.1): Schaltet sich das Display waehrend einer Serie ab, pausiert der // Browser Bild-Decoding und Canvas — dann feuert weder onload noch onerror und das // Promise loeste NIE auf. Das passiert VOR dem Sichern in der Queue, die betroffenen @@ -966,7 +997,7 @@ async function resizeImage(file, maxSide, timeoutMs) { c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); - c.toBlob(b => resolve(b || file), 'image/jpeg', 0.85); + c.toBlob(b => resolve(b || file), 'image/jpeg', typeof quality === 'number' ? quality : 0.85); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; img.src = url; @@ -1464,6 +1495,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const helpBtn = document.getElementById('help-btn'); if (helpBtn) helpBtn.addEventListener('click', openHelpModal); + refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen }); /** @@ -3243,28 +3275,58 @@ async function openCameraModal(orderId) { } } + /* Echtes Kamerafoto in voller Sensoraufloesung. + * + * Ein Standbild aus dem Video-Stream hat hoechstens Stream-Aufloesung (hier bis 4K + * angefordert, real oft weniger) — die Kamera selbst kann ein Vielfaches. Auf + * Typenschildern und Zaehlerstaenden war deshalb wenig zu erkennen. ImageCapture + * .takePhoto() loest dagegen richtig aus und liefert die native JPEG-Datei der + * Kamera, inklusive EXIF. Kann der Browser das nicht (iOS/Safari, aeltere Stände), + * bleibt das Standbild als Rueckfallebene. + */ + async function grabFullResolution() { + try { + if (typeof ImageCapture === 'undefined' || !stream) return null; + const track = stream.getVideoTracks()[0]; + if (!track || typeof track.getCapabilities !== 'function') return null; + const cap = new ImageCapture(track); + const blob = await cap.takePhoto(); + return (blob && blob.size > 0) ? blob : null; + } catch (_) { + return null; // z.B. „takePhoto not supported" — Standbild nehmen + } + } + async function capture() { if (!stream || !video.videoWidth) { showToast('Kamera noch nicht bereit', 'warn'); return; } - // Frame sofort abgreifen (schnell), damit rasches Mehrfach-Knipsen flüssig bleibt. - const c = document.createElement('canvas'); - c.width = video.videoWidth; - c.height = video.videoHeight; - c.getContext('2d').drawImage(video, 0, 0, c.width, c.height); shutter.classList.add('flash'); setTimeout(() => shutter.classList.remove('flash'), 150); shots++; updateCount(); - const raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.92)); + + let raw = await grabFullResolution(); + if (!raw) { + // Rueckfallebene: Standbild aus dem laufenden Stream + const c = document.createElement('canvas'); + c.width = video.videoWidth; + c.height = video.videoHeight; + c.getContext('2d').drawImage(video, 0, 0, c.width, c.height); + raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.95)); + } if (!raw) { shots = Math.max(0, shots - 1); updateCount(); showToast('Foto fehlgeschlagen', 'error'); return; } const thumb = addThumb(URL.createObjectURL(raw)); try { - // Auf max. 2000px verkleinern (wie beim normalen Upload) und SOFORT persistent sichern - let blob = raw; - try { blob = await resizeImage(raw, 2000); } catch (_) { blob = raw; } + // Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto) const name = 'foto_' + Date.now() + '_' + shots + '.jpg'; - const qid = await offline.enqueuePhoto(orderId, blob, name); + const qid = await offline.enqueuePhoto(orderId, raw, name); + if (photoCfg.photo_maxside > 0) { + try { + const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100); + if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small); + } catch (_) { /* Original bleibt gesichert */ } + } thumb.dataset.qid = qid; thumb.classList.remove('saving'); thumb.classList.add('pending'); diff --git a/lib/api.js b/lib/api.js index e1e0047..4b7a918 100644 --- a/lib/api.js +++ b/lib/api.js @@ -103,6 +103,11 @@ return request('/orders.php' + (qs ? '?' + qs : '')); } + // Fotoeinstellungen (Admin → Bericht-Modul): Verkleinern ja/nein, JPEG-Qualitaet + async function getConfig() { + return request('/config.php'); + } + async function getOrder(id) { return request('/orders.php?id=' + id); } @@ -392,5 +397,6 @@ getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages, listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment, + getConfig, }; })(); diff --git a/lib/offline.js b/lib/offline.js index 98df374..08e382c 100644 --- a/lib/offline.js +++ b/lib/offline.js @@ -29,9 +29,49 @@ }); await updateBadge(); emitChange(); + registerBackgroundSync(); // auch ohne offene App nachliefern return id; } + // Blob eines bereits gesicherten Queue-Items ersetzen. Gebraucht fuer das + // Verkleinern: gesichert wird zuerst das Original, die kleinere Fassung ersetzt + // es erst danach. Klappt das Verkleinern nicht, bleibt das Original — nie ein Loch. + async function replaceQueuedBlob(id, fileBlob) { + const items = await idb.queueAll().catch(() => []); + const it = items.find(x => x.id === id); + if (!it) return false; // schon hochgeladen und geloescht + it.data = await fileBlob.arrayBuffer(); + it.mime = fileBlob.type || it.mime || 'image/jpeg'; + await idb.queueUpdate(it); + return true; + } + + // Background Sync: der Browser holt die Queue auch dann nach, wenn die App gar + // nicht offen ist (Display aus, App weggewischt). Der Service Worker laedt selbst + // hoch — die Auth steckt im HttpOnly-Cookie awl_sso und geht bei same-origin + // automatisch mit. Ohne das lief Nachliefern nur, solange die Seite im + // Vordergrund war; im Hintergrund friert der Browser Timer ein. + async function registerBackgroundSync() { + try { + if (!('serviceWorker' in navigator) || !('SyncManager' in window)) return false; + const reg = await navigator.serviceWorker.ready; + if (!reg.sync) return false; + await reg.sync.register('photo-queue'); + // Zusaetzlich, falls der Browser es gewaehrt (installierte PWA): ein + // periodischer Weckruf. Faengt den Fall ab, dass gar kein sync-Ereignis + // mehr kommt, weil das Geraet lange offline war. + try { + if (reg.periodicSync && navigator.permissions) { + const st = await navigator.permissions.query({ name: 'periodic-background-sync' }); + if (st.state === 'granted') { + await reg.periodicSync.register('photo-queue', { minInterval: 15 * 60 * 1000 }); + } + } + } catch (_) { /* nicht unterstuetzt — der normale Sync reicht */ } + return true; + } catch (_) { return false; } // z.B. Berechtigung verweigert — Timer bleiben als Netz + } + function emitChange() { try { window.dispatchEvent(new CustomEvent('queue-changed')); } catch (_) {} } @@ -191,6 +231,22 @@ } } + // Der Service Worker meldet, wenn er im Hintergrund etwas hochgeladen hat + try { + navigator.serviceWorker.addEventListener('message', (e) => { + const d = e.data || {}; + if (d.type !== 'photo-uploaded') return; + updateBadge(); + emitChange(); + try { + window.dispatchEvent(new CustomEvent('photo-uploaded', { + detail: { queueId: d.queueId, relpath: d.relpath, orderId: d.orderId, + filename: d.filename, duplicate: !!d.duplicate }, + })); + } catch (_) {} + }); + } catch (_) {} + window.addEventListener('online', () => { updateBadge(); syncQueue(); }); window.addEventListener('offline', updateBadge); @@ -214,5 +270,5 @@ }); window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed, - acquireWakeLock, releaseWakeLock }; + acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync }; })(); diff --git a/sw.js b/sw.js index cc46a8b..2df93c7 100644 --- a/sw.js +++ b/sw.js @@ -168,3 +168,127 @@ self.addEventListener('message', (e) => { e.waitUntil(caches.delete(MEDIA_CACHE)); } }); + +/* ========================================================================== + * Background Sync — Fotos nachliefern, auch wenn die App gar nicht offen ist + * + * Bis hierher lief das Nachliefern ausschliesslich in der Seite selbst: über + * das online-Ereignis, „App wieder sichtbar" und einen 15-Sekunden-Takt. Ist + * das Display aus oder die App weggewischt, friert der Browser diese Timer ein + * — es passierte schlicht nichts, bis jemand die App wieder aufmachte. + * + * Der Service Worker läuft unabhängig davon. Er greift auf dieselbe + * IndexedDB-Queue zu (baustelle-pwa-v1 / Store "queue") und lädt selbst hoch; + * die Anmeldung steckt im HttpOnly-Cookie awl_sso und geht bei same-origin + * automatisch mit — deshalb braucht es hier kein Token-Handling. + * + * Gelöscht wird ein Eintrag nur bei BESTÄTIGTEM Upload: HTTP 2xx, echtes JSON + * und ein relpath in der Antwort. Alles andere bleibt liegen; wir lehnen das + * Sync-Versprechen ab, dann plant der Browser den nächsten Versuch selbst. + * ========================================================================== */ + +const QUEUE_DB = 'baustelle-pwa-v1'; +const QUEUE_STORE = 'queue'; +const UPLOAD_BASE = '/custom/bericht/api/orders.php'; + +function queueDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(QUEUE_DB); // KEINE Version angeben: + req.onsuccess = () => resolve(req.result); // das Schema legt die Seite an + req.onerror = () => reject(req.error); + }); +} + +function queueRead(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction(QUEUE_STORE, 'readonly'); + const req = tx.objectStore(QUEUE_STORE).getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); +} + +function queueRemove(db, id) { + return new Promise((resolve, reject) => { + const tx = db.transaction(QUEUE_STORE, 'readwrite'); + tx.objectStore(QUEUE_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function tellClients(msg) { + const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' }); + list.forEach(c => { try { c.postMessage(msg); } catch (_) {} }); +} + +/* Ein Foto hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload, + * sonst wird geworfen — der Aufrufer lässt den Eintrag dann in der Queue. */ +async function uploadQueued(item) { + const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' }); + const fd = new FormData(); + fd.append('file', blob, item.filename || 'foto.jpg'); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 60000); // haengende Verbindung nicht ewig halten + let r; + try { + r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', { + method: 'POST', + body: fd, + credentials: 'same-origin', + signal: ctrl.signal, + }); + } finally { + clearTimeout(timer); + } + + if (!r.ok) throw new Error('HTTP ' + r.status); + + // 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert. + const ct = (r.headers.get('content-type') || '').toLowerCase(); + if (ct.indexOf('application/json') === -1) throw new Error('Antwort ist kein JSON'); + + const data = await r.json(); + if (!data || typeof data.relpath !== 'string' || data.relpath === '') { + throw new Error('Upload nicht bestaetigt (kein relpath)'); + } + return data; +} + +async function drainQueue() { + const db = await queueDb(); + const items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed); + if (!items.length) return; + + let left = 0; + for (const it of items) { + try { + const res = await uploadQueued(it); + await queueRemove(db, it.id); + await tellClients({ + type: 'photo-uploaded', + queueId: it.id, + relpath: res.relpath, + orderId: it.order_id, + filename: it.filename, + duplicate: !!res.duplicate, + }); + } catch (_) { + left++; // bleibt in der Queue, naechster Sync versucht es erneut + } + } + + // Noch etwas offen? Ablehnen, damit der Browser erneut zustellt. + if (left) throw new Error(left + ' Foto(s) weiterhin offen'); +} + +self.addEventListener('sync', (e) => { + if (e.tag === 'photo-queue') e.waitUntil(drainQueue()); +}); + +/* Periodic Background Sync, falls der Browser ihn gewährt (installierte PWA, + * Chrome/Android). Fängt den Fall ab, dass gar kein sync-Ereignis mehr kommt. */ +self.addEventListener('periodicsync', (e) => { + if (e.tag === 'photo-queue') e.waitUntil(drainQueue().catch(() => {})); +});