diff --git a/app.css b/app.css
index 01e0418..62e7498 100644
--- a/app.css
+++ b/app.css
@@ -1174,3 +1174,108 @@ body {
.ship-sign-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.ship-sign-side { border-right: none; border-bottom: 1px solid #333; }
}
+
+/* ============================================================
+ * Live-Kamera (Schnellschuss + Filmstreifen)
+ * ============================================================ */
+.camera-modal { background: #000; }
+.camera-modal .cam-stage {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ background: #000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+/* contain: die Vorschau zeigt exakt das, was gespeichert wird (wichtig für Doku-Fotos) */
+.camera-modal #cam-video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ background: #000;
+}
+.camera-modal .cam-hint {
+ position: absolute;
+ color: #fff;
+ font-size: 14px;
+ opacity: 0.85;
+ pointer-events: none;
+ text-align: center;
+ padding: 0 20px;
+}
+.camera-modal .cam-filmstrip {
+ display: flex;
+ gap: 8px;
+ padding: 8px 10px;
+ overflow-x: auto;
+ background: rgba(0,0,0,0.85);
+ -webkit-overflow-scrolling: touch;
+}
+.camera-modal .cam-filmstrip:empty { padding: 0; }
+.cam-thumb {
+ position: relative;
+ flex: 0 0 auto;
+ width: 60px;
+ height: 60px;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 2px solid #444;
+ background: #222;
+}
+.cam-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.cam-thumb .cam-thumb-badge {
+ position: absolute;
+ right: 2px;
+ bottom: 2px;
+ font-size: 11px;
+ line-height: 1;
+ padding: 1px 3px;
+ border-radius: 6px;
+ background: rgba(0,0,0,0.65);
+ color: #fff;
+}
+.cam-thumb.saving { border-color: #888; }
+.cam-thumb.saving .cam-thumb-badge::after { content: '💾'; }
+.cam-thumb.pending { border-color: #e0a300; }
+.cam-thumb.pending .cam-thumb-badge::after { content: '⏳'; }
+.cam-thumb.done { border-color: #2ecc71; }
+.cam-thumb.done .cam-thumb-badge::after { content: '✓'; }
+.cam-thumb.error { border-color: #e74c3c; }
+.cam-thumb.error .cam-thumb-badge::after { content: '⚠'; }
+.cam-thumb .cam-thumb-del {
+ position: absolute;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(200,30,30,0.78);
+ color: #fff;
+ border: none;
+ font-size: 22px;
+ cursor: pointer;
+}
+.cam-thumb.confirm-del .cam-thumb-del { display: flex; }
+.camera-modal .cam-controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 20px calc(14px + env(safe-area-inset-bottom));
+ background: #000;
+}
+.camera-modal .cam-count { flex: 1; color: #bbb; font-size: 13px; text-align: left; }
+.camera-modal .cam-done-wrap { flex: 1; display: flex; justify-content: flex-end; }
+.cam-shutter {
+ flex: 0 0 auto;
+ width: 68px;
+ height: 68px;
+ border-radius: 50%;
+ background: #fff;
+ border: 4px solid rgba(255,255,255,0.35);
+ box-shadow: 0 0 0 2px #000 inset;
+ cursor: pointer;
+ transition: transform 0.08s ease, background 0.08s ease;
+}
+.cam-shutter:active { transform: scale(0.92); }
+.cam-shutter.flash { background: #ffd34d; }
diff --git a/app.js b/app.js
index c7cbaa5..f048518 100644
--- a/app.js
+++ b/app.js
@@ -769,7 +769,9 @@ router.on('/orders/:id', async (args) => {
const camInput = document.getElementById('camera-input');
const galInput = document.getElementById('gallery-input');
- document.getElementById('btn-take-photo').onclick = () => camInput.click();
+ // Live-Kamera (bleibt im Kamera-Modus für mehrere Fotos). Fällt intern auf
+ // den nativen camera-input zurück, wenn getUserMedia nicht verfügbar ist.
+ document.getElementById('btn-take-photo').onclick = () => openCameraModal(args.id);
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
async function handleFiles(files) {
@@ -788,20 +790,17 @@ router.on('/orders/:id', async (args) => {
});
async function uploadPhoto(orderId, file) {
- showToast('Optimiere & sende ' + file.name);
- const blob = await resizeImage(file, 2000);
- if (!navigator.onLine) {
- await offline.enqueuePhoto(orderId, blob, file.name);
- showToast('Offline — Foto in Queue', 'warn');
- return;
- }
- try {
- await api.uploadOrderPhoto(orderId, blob, file.name);
- showToast('✓ ' + file.name + ' hochgeladen');
- } catch (e) {
- await offline.enqueuePhoto(orderId, blob, file.name);
- showToast('Upload fehlgeschlagen — in Queue', 'error');
- }
+ // 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; }
+ const name = file.name || ('foto_' + Date.now() + '.jpg');
+ await offline.enqueuePhoto(orderId, blob, name);
+ if (navigator.onLine) showToast('Sende ' + name + '…');
+ else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn');
+ await offline.syncQueue();
}
async function resizeImage(file, maxSide) {
@@ -2818,6 +2817,271 @@ async function openVoiceModal(orderId) {
};
}
+/* ============================================================
+ * LIVE-KAMERA — Schnellschuss + Filmstreifen
+ * Bleibt im Kamera-Modus: mehrere Fotos ohne Rauswerfen.
+ * Jedes Foto wird SOFORT persistent in die Offline-Queue geschrieben
+ * (kein Datenverlust) und dann im Hintergrund hochgeladen.
+ * ============================================================ */
+async function openCameraModal(orderId) {
+ // Kein getUserMedia (alter Browser / unsicherer Kontext)? → System-Kamera nutzen
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ const ci = document.getElementById('camera-input');
+ if (ci) ci.click();
+ return;
+ }
+
+ const modal = document.createElement('div');
+ modal.className = 'fullscreen-modal camera-modal';
+ modal.innerHTML = `
+
+
+
+
Kamera wird gestartet…
+
+
+
+ `;
+ document.body.appendChild(modal);
+
+ const video = modal.querySelector('#cam-video');
+ const strip = modal.querySelector('#cam-strip');
+ const hint = modal.querySelector('#cam-hint');
+ const countEl = modal.querySelector('#cam-count');
+ const shutter = modal.querySelector('#cam-shutter');
+
+ let stream = null;
+ let facing = 'environment';
+ let shots = 0;
+ let gen = 0; // Generations-Token: entwertet in-flight getUserMedia-Aufrufe (Schließen/Flip)
+
+ function stopStream() {
+ if (stream) { try { stream.getTracks().forEach(t => t.stop()); } catch (_) {} stream = null; }
+ try { video.srcObject = null; } catch (_) {}
+ }
+
+ // Upload bestätigt → passende Miniatur auf „fertig" setzen und relpath merken (für Löschen)
+ function onUploaded(e) {
+ const d = e.detail || {};
+ const thumb = strip.querySelector('.cam-thumb[data-qid="' + d.queueId + '"]');
+ if (!thumb) return;
+ if (d.relpath) thumb.dataset.relpath = d.relpath;
+ thumb.classList.remove('pending', 'saving');
+ thumb.classList.add('done');
+ }
+ window.addEventListener('photo-uploaded', onUploaded);
+
+ // Aufräumen bei Schließen / Android-Back: Kamera aus, Listener weg, Blob-URLs freigeben,
+ // Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos).
+ pushModal(modal, () => {
+ gen++; // entwertet einen evtl. noch laufenden getUserMedia-Aufruf
+ stopStream();
+ window.removeEventListener('photo-uploaded', onUploaded);
+ strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
+ // Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip)
+ if (shots > 0) { try { router.navigate(); } catch (_) {} }
+ });
+
+ modal.querySelector('#cam-close').onclick = () => closeModal(modal);
+ modal.querySelector('#cam-done').onclick = () => closeModal(modal);
+ modal.querySelector('#cam-flip').onclick = () => {
+ facing = (facing === 'environment') ? 'user' : 'environment';
+ startStream();
+ };
+
+ function updateCount() {
+ countEl.textContent = shots ? (shots + (shots === 1 ? ' Foto' : ' Fotos')) : '';
+ }
+
+ async function startStream() {
+ const my = ++gen;
+ stopStream();
+ hint.style.display = '';
+ hint.textContent = 'Kamera wird gestartet…';
+ try {
+ const s = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: { ideal: facing }, width: { ideal: 3840 }, height: { ideal: 2160 } },
+ audio: false,
+ });
+ // Inzwischen geschlossen oder neuer Start (Flip) angefordert? → diesen Stream sofort beenden,
+ // damit keine verwaiste Kamera (LED an) zurückbleibt.
+ if (my !== gen || !document.body.contains(modal)) {
+ try { s.getTracks().forEach(t => t.stop()); } catch (_) {}
+ return;
+ }
+ stream = s;
+ video.srcObject = stream;
+ await video.play().catch(() => {});
+ hint.style.display = 'none';
+ } catch (err) {
+ if (my !== gen || !document.body.contains(modal)) return; // abgebrochen, nicht mehr relevant
+ console.warn('[Kamera]', err);
+ showToast('Kamera nicht verfügbar — nutze System-Kamera', 'warn');
+ closeModal(modal);
+ const ci = document.getElementById('camera-input');
+ if (ci) ci.click();
+ }
+ }
+
+ function addThumb(url) {
+ const t = document.createElement('div');
+ t.className = 'cam-thumb saving';
+ t.innerHTML = '
'
+ + '';
+ // Tipp auf Miniatur → Löschen-Overlay ein/aus
+ t.querySelector('img').onclick = () => t.classList.toggle('confirm-del');
+ t.querySelector('.cam-thumb-del').onclick = (ev) => { ev.stopPropagation(); deleteThumb(t); };
+ strip.prepend(t);
+ return t;
+ }
+
+ async function deleteThumb(t) {
+ if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz
+ const relpath = t.dataset.relpath;
+ const qid = t.dataset.qid;
+ if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
+ // Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
+ if (!relpath && qid) {
+ if (!confirm('Dieses Foto ist noch nicht hochgeladen. Wirklich löschen?')) return;
+ }
+ t.dataset.deleting = '1';
+ const delBtn = t.querySelector('.cam-thumb-del');
+ if (delBtn) delBtn.disabled = true;
+ try {
+ if (relpath) {
+ await api.deletePhoto(relpath); // schon hochgeladen → serverseitig löschen
+ } else {
+ await idb.queueDelete(Number(qid)); // noch in Queue → aus Queue entfernen
+ await offline.updateBadge();
+ }
+ try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {}
+ t.remove();
+ shots = Math.max(0, shots - 1);
+ updateCount();
+ showToast('Foto gelöscht');
+ } catch (e) {
+ t.dataset.deleting = '';
+ if (delBtn) delBtn.disabled = false;
+ showToast('Löschen fehlgeschlagen: ' + (e.message || ''), 'error');
+ }
+ }
+
+ 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));
+ 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; }
+ const name = 'foto_' + Date.now() + '_' + shots + '.jpg';
+ const qid = await offline.enqueuePhoto(orderId, blob, name);
+ thumb.dataset.qid = qid;
+ thumb.classList.remove('saving');
+ thumb.classList.add('pending');
+ offline.syncQueue(); // im Hintergrund hochladen (löscht nur bei bestätigtem Erfolg)
+ } catch (e) {
+ thumb.classList.remove('saving');
+ thumb.classList.add('error');
+ const quota = e && (e.name === 'QuotaExceededError' || /quota/i.test(e.message || ''));
+ showToast(quota
+ ? '⚠ Speicher voll — bitte erst vorhandene Fotos hochladen, dann erneut aufnehmen'
+ : ('Foto sichern fehlgeschlagen: ' + (e.message || '')), 'error');
+ }
+ }
+
+ shutter.onclick = capture;
+ startStream();
+}
+
+/* ============================================================
+ * UPLOAD-WARTESCHLANGE — Recovery-UI (per Klick auf das Status-Badge)
+ * Zeigt offene und fehlgeschlagene Uploads, erlaubt erneutes Senden und
+ * das Retten fehlgeschlagener Fotos per Teilen/Speichern. So ist ein Foto
+ * NIE in einer stillen Sackgasse.
+ * ============================================================ */
+async function openUploadQueueModal() {
+ const items = await offline.listQueue().catch(() => []);
+ if (!items.length) {
+ showToast(navigator.onLine ? 'Alle Fotos hochgeladen ✓' : 'Keine offenen Uploads');
+ return;
+ }
+ const pending = items.filter(i => !i.failed);
+ const failed = items.filter(i => i.failed);
+
+ const modal = document.createElement('div');
+ modal.className = 'fullscreen-modal queue-modal';
+ modal.innerHTML = `
+
+
+
${pending.length} warten auf Upload${failed.length ? ', ' + failed.length + ' fehlgeschlagen' : ''}.
+
+ ${failed.length ? '
' : ''}
+ ${failed.length ? '
' : ''}
+
Fotos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.
+
+ `;
+ document.body.appendChild(modal);
+ pushModal(modal);
+
+ modal.querySelector('#q-close').onclick = () => closeModal(modal);
+ modal.querySelector('#q-sync').onclick = () => {
+ showToast('Synchronisiere…');
+ offline.syncQueue();
+ closeModal(modal);
+ };
+ const retryBtn = modal.querySelector('#q-retry');
+ if (retryBtn) retryBtn.onclick = async () => {
+ await offline.retryFailed();
+ showToast('Erneut senden gestartet…');
+ closeModal(modal);
+ };
+ const shareBtn = modal.querySelector('#q-share');
+ if (shareBtn) shareBtn.onclick = async () => {
+ const files = failed.map(it => ({
+ blob: new Blob([it.data], { type: it.mime || 'image/jpeg' }),
+ filename: it.filename || 'foto.jpg',
+ mime: it.mime || 'image/jpeg',
+ }));
+ if (!files.length) return;
+ await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos');
+ };
+}
+
+// Status-Badge in der Kopfzeile klickbar machen → Upload-Warteschlange öffnen
+(function wireStatusBadge() {
+ const badge = document.getElementById('status-badge');
+ if (badge) {
+ badge.style.cursor = 'pointer';
+ badge.title = 'Upload-Warteschlange anzeigen';
+ badge.addEventListener('click', () => openUploadQueueModal());
+ }
+})();
+
/* ============================================================
* SKIZZEN-EDITOR (Touch-fähig, einfache Vektor-Zeichnung)
* ============================================================ */
diff --git a/lib/api.js b/lib/api.js
index a9f4852..a65405f 100644
--- a/lib/api.js
+++ b/lib/api.js
@@ -30,14 +30,40 @@
if (!headers['Content-Type'] && !(opts.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
- const r = await fetch(API_BASE + path, { ...opts, headers });
+ // Optionaler Timeout: bricht hängende Requests ab, statt ewig zu warten
+ // (schwaches/totes Netz auf der Baustelle → Foto landet zuverlässig in der Queue)
+ let signal = opts.signal;
+ let toHandle = null;
+ if (opts.timeoutMs && typeof AbortController !== 'undefined') {
+ const ctrl = new AbortController();
+ signal = ctrl.signal;
+ toHandle = setTimeout(() => { try { ctrl.abort(); } catch (_) {} }, opts.timeoutMs);
+ }
+ let r;
+ try {
+ r = await fetch(API_BASE + path, { ...opts, headers, signal });
+ } finally {
+ if (toHandle) clearTimeout(toHandle);
+ }
if (r.status === 401) {
await clearToken();
window.location.hash = '#/login';
- throw new Error('Nicht authentifiziert');
+ const e = new Error('Nicht authentifiziert'); e.status = 401; throw e;
}
- const data = await r.json().catch(() => ({}));
- if (!r.ok) throw new Error(data.error || 'API-Fehler');
+ // Antwort MUSS JSON sein. Ein 2xx mit HTML (z.B. Apache/Proxy-Login- oder
+ // Wartungsseite bei abgelaufener Session, ErrorDocument 200) darf NIEMALS als
+ // Erfolg gelten — sonst würde ein Foto fälschlich als hochgeladen aus der Queue
+ // gelöscht und wäre verloren.
+ const ct = (r.headers.get('Content-Type') || '').toLowerCase();
+ let data = {};
+ if (ct.includes('application/json')) {
+ data = await r.json().catch(() => ({}));
+ } else if (r.ok) {
+ const e = new Error('Unerwartete Server-Antwort (kein JSON)');
+ e.status = r.status; e.nonJson = true;
+ throw e;
+ }
+ if (!r.ok) { const e = new Error(data.error || 'API-Fehler'); e.status = r.status; throw e; }
return data;
}
@@ -82,13 +108,27 @@
return request('/orders.php?id=' + id + '&action=photos');
}
+ // Semantik: at-least-once. Geht die Server-Antwort NACH dem Speichern verloren
+ // (z.B. Netz-Abbruch, Proxy-2xx-HTML), gilt der Upload als unbestätigt und wird
+ // wiederholt → das Foto kann dadurch doppelt auf dem Server landen (der Dateiname
+ // enthält uniqid(), es wird also nicht überschrieben). Bewusst gewählt: lieber ein
+ // löschbares Duplikat als ein verlorenes Foto.
async function uploadOrderPhoto(orderId, fileBlob, filename) {
const fd = new FormData();
fd.append('file', fileBlob, filename || 'photo.jpg');
- return request('/orders.php?id=' + orderId + '&action=upload_photo', {
+ const res = await request('/orders.php?id=' + orderId + '&action=upload_photo', {
method: 'POST',
body: fd,
+ timeoutMs: 45000,
});
+ // Der Server MUSS einen relpath liefern, wenn er die Datei wirklich gespeichert hat.
+ // Fehlt er, gilt der Upload als NICHT bestätigt → Foto bleibt in der Queue (Retry).
+ if (!res || typeof res.relpath !== 'string' || res.relpath === '') {
+ const e = new Error('Upload nicht bestätigt (kein relpath)');
+ e.uploadUnconfirmed = true;
+ throw e;
+ }
+ return res;
}
async function createOrder(payload) {
diff --git a/lib/idb.js b/lib/idb.js
index 247d5a3..62893ed 100644
--- a/lib/idb.js
+++ b/lib/idb.js
@@ -85,5 +85,16 @@
});
}
- window.idb = { get, set, del, queuePush, queueAll, queueDelete };
+ // Item aktualisieren (z.B. Versuchszähler / Fehler-Markierung) — put upsert-t per keyPath id
+ async function queueUpdate(item) {
+ const db = await open();
+ return new Promise((res, rej) => {
+ const tx = db.transaction('queue', 'readwrite');
+ tx.objectStore('queue').put(item);
+ tx.oncomplete = () => res();
+ tx.onerror = () => rej(tx.error);
+ });
+ }
+
+ window.idb = { get, set, del, queuePush, queueAll, queueDelete, queueUpdate };
})();
diff --git a/lib/offline.js b/lib/offline.js
index 737c02a..c721f27 100644
--- a/lib/offline.js
+++ b/lib/offline.js
@@ -1,9 +1,18 @@
-/* Offline-Queue für Foto-Uploads.
- * Wenn der Upload fehlschlägt (offline), wird er in IndexedDB abgelegt
- * und beim nächsten Online-Event automatisch nachgesendet.
+/* Offline-Queue für Foto-Uploads (Persist-First / Write-Ahead).
+ *
+ * WICHTIG gegen Datenverlust:
+ * Jedes Foto wird vom Aufrufer ZUERST via enqueuePhoto() persistent in IndexedDB
+ * geschrieben und ERST DANACH hochgeladen. Ein Item wird nur nach BESTÄTIGTEM
+ * Upload (HTTP 2xx) aus der Queue gelöscht. Dadurch überlebt jedes Foto:
+ * - fehlendes/schwaches Netz (navigator.onLine lügt bei WLAN ohne Internet)
+ * - hängende Uploads (Timeout in api.uploadOrderPhoto)
+ * - App-Schließen / OS-Kill mitten im Upload
*/
(function () {
let syncing = false;
+ let syncAgain = false; // wurde während eines Laufs erneut angefragt?
+ let lastUnsynced = 0; // ALLE noch nicht bestätigten Items (für Beforeunload-Warnung)
+ const MAX_ATTEMPTS = 6; // nach so vielen Dauerfehlern → Quarantäne (blockiert Queue nicht)
async function enqueuePhoto(orderId, fileBlob, filename) {
// Blob → ArrayBuffer für IndexedDB-Speicherung
@@ -14,61 +23,154 @@
filename,
mime: fileBlob.type || 'image/jpeg',
data: buf,
+ attempts: 0,
+ failed: false,
created: Date.now(),
});
- updateBadge();
+ await updateBadge();
+ emitChange();
return id;
}
+ function emitChange() {
+ try { window.dispatchEvent(new CustomEvent('queue-changed')); } catch (_) {}
+ }
+
+ // Netzfehler / Timeout / Auth / Serverfehler → Item behalten (später erneut versuchen).
+ // NUR echte Client-Fehler (400/413/415: falscher Dateityp / zu groß) sind dauerhaft.
+ function isTransient(err) {
+ if (!err) return true;
+ if (err.name === 'AbortError') return true; // Timeout via AbortController
+ if (err instanceof TypeError) return true; // fetch: „Failed to fetch"
+ if (err.uploadUnconfirmed) return true; // 2xx, aber kein relpath → nicht bestätigt
+ if (err.nonJson) return true; // 2xx-HTML von Proxy/Apache → Auth/Proxy-Problem
+ // 5xx (Deploy-Neustart, PHP-Fatal, DB-Lock), 429 (Rate-Limit), 408 (Timeout) sind vorübergehend
+ if (err.status && (err.status >= 500 || err.status === 429 || err.status === 408)) return true;
+ const m = (err.message || '').toLowerCase();
+ if (m.includes('fetch') || m.includes('network') || m.includes('authentifiziert')) return true;
+ return false;
+ }
+
async function syncQueue() {
- if (syncing) return;
+ // Läuft schon einer? Dann nur merken, dass danach nochmal geprüft werden soll
+ // (so werden Fotos erfasst, die während eines laufenden Syncs neu dazukamen).
+ if (syncing) { syncAgain = true; return; }
if (!navigator.onLine) return;
syncing = true;
try {
- const items = await idb.queueAll();
- for (const it of items) {
- try {
- if (it.type === 'photo') {
- const blob = new Blob([it.data], { type: it.mime });
- await api.uploadOrderPhoto(it.order_id, blob, it.filename);
+ do {
+ syncAgain = false;
+ const items = await idb.queueAll();
+ let stoppedByNetwork = false;
+ for (const it of items) {
+ if (it.failed) continue; // quarantänisiert: behalten, überspringen
+ if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; }
+ try {
+ const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
+ const res = await api.uploadOrderPhoto(it.order_id, blob, it.filename);
+ await idb.queueDelete(it.id);
+ try {
+ window.dispatchEvent(new CustomEvent('photo-uploaded', {
+ detail: {
+ queueId: it.id,
+ relpath: res && res.relpath,
+ orderId: it.order_id,
+ filename: it.filename,
+ },
+ }));
+ } catch (_) {}
+ } catch (e) {
+ console.warn('[Sync] Item ' + it.id + ' fehlgeschlagen:', e);
+ if (isTransient(e)) {
+ // Netzproblem → abbrechen; ALLES bleibt erhalten, nächster Versuch später
+ stoppedByNetwork = true;
+ break;
+ }
+ // Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
+ // aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
+ it.attempts = (it.attempts || 0) + 1;
+ it.last_error = (e && e.message) || 'Fehler';
+ if (it.attempts >= MAX_ATTEMPTS) it.failed = true;
+ try { await idb.queueUpdate(it); } catch (_) {}
}
- await idb.queueDelete(it.id);
- } catch (e) {
- console.warn('Sync failed for item', it.id, e);
- // Nicht weiter versuchen wenn ein Item failed
- break;
}
- }
+ if (stoppedByNetwork) break;
+ } while (syncAgain);
} finally {
syncing = false;
- updateBadge();
+ await updateBadge();
+ emitChange();
}
}
+ async function queueCount() {
+ try { return (await idb.queueAll()).length; } catch (_) { return 0; }
+ }
+
+ // Alle Queue-Items (für das Recovery-UI: Warteschlange ansehen / erneut senden / teilen)
+ async function listQueue() {
+ return idb.queueAll().catch(() => []);
+ }
+
+ // Quarantänisierte Items reaktivieren und erneut versuchen
+ async function retryFailed() {
+ const items = await idb.queueAll().catch(() => []);
+ for (const it of items) {
+ if (it.failed) {
+ it.failed = false;
+ it.attempts = 0;
+ delete it.last_error;
+ try { await idb.queueUpdate(it); } catch (_) {}
+ }
+ }
+ await updateBadge();
+ emitChange();
+ syncQueue();
+ }
+
async function updateBadge() {
- const items = await idb.queueAll();
+ const items = await idb.queueAll().catch(() => []);
+ const pending = items.filter(i => !i.failed).length;
+ const failed = items.filter(i => i.failed).length;
+ lastUnsynced = items.length; // failed zählt mit — sie sind ebenfalls noch nicht gesichert
const badge = document.getElementById('status-badge');
if (!badge) return;
if (!navigator.onLine) {
badge.textContent = items.length ? '🔴 ' + items.length : '🔴';
- badge.title = items.length + ' Uploads warten';
- } else if (items.length) {
- badge.textContent = '🟡 ' + items.length;
- badge.title = items.length + ' werden synchronisiert';
+ badge.title = items.length ? items.length + ' Fotos warten (offline, gesichert)' : 'Offline';
+ } else if (pending) {
+ badge.textContent = '🟡 ' + pending;
+ badge.title = pending + ' Fotos werden hochgeladen…';
+ } else if (failed) {
+ badge.textContent = '⚠️ ' + failed;
+ badge.title = failed + ' Uploads fehlgeschlagen — bitte prüfen';
} else {
badge.textContent = '🟢';
- badge.title = 'Online';
+ badge.title = 'Online — alle Fotos gesichert';
}
}
window.addEventListener('online', () => { updateBadge(); syncQueue(); });
window.addEventListener('offline', updateBadge);
+
+ // Warnung, wenn die App mit noch nicht hochgeladenen Fotos geschlossen/neu geladen wird.
+ // Feuert in einer Hash-Router-SPA NICHT bei normaler In-App-Navigation, nur beim echten Verlassen.
+ // lastUnsynced schließt quarantänisierte (failed) Fotos ein — die sind erst recht gefährdet.
+ window.addEventListener('beforeunload', (e) => {
+ if (lastUnsynced > 0) { e.preventDefault(); e.returnValue = ''; return ''; }
+ });
+
+ // Zurück in den Vordergrund → sofort erneut synchronisieren
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && navigator.onLine) syncQueue();
+ });
+
document.addEventListener('DOMContentLoaded', () => {
updateBadge();
if (navigator.onLine) syncQueue();
- // Periodischer Sync alle 30s
- setInterval(() => { if (navigator.onLine) syncQueue(); }, 30000);
+ // Periodischer Sync alle 15s (fängt Items ein, deren erster Versuch scheiterte)
+ setInterval(() => { if (navigator.onLine) syncQueue(); }, 15000);
});
- window.offline = { enqueuePhoto, syncQueue, updateBadge };
+ window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed };
})();