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
+ +
+
+ +
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 = ` +
+ +
☁ Upload-Warteschlange
+ +
+
+

${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 }; })();