Feature: Live-Kamera (Schnellschuss + Filmstreifen), Datenverlust-Schutz (Persist-First) [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
- Kamera bleibt nach jedem Schnappschuss offen (getUserMedia Live-Video statt native capture-Input, der nach jedem Foto schliesst) - Filmstreifen mit Status-Badges pro Foto (💾 gesichert → ⏳ lädt hoch → ✓ fertig) - Persist-First: Foto zuerst in IndexedDB schreiben, Upload erst danach - Foto verlässt Queue NUR nach HTTP-2xx mit gültigem relpath - 2xx-HTML (abgelaufene Session, Proxy-Loginseite) wird als Fehler erkannt - HTTP-Status auf Error-Objekt: 5xx/429/408 sind transient (nicht quarantänisiert) - 45-Sekunden-Upload-Timeout via AbortController - Quarantäne nach 6 Fehlversuchen, Recovery via Status-Badge-Tipp - Beforeunload-Warnung wenn Fotos noch nicht gesichert - idb.queueUpdate() für state-preserving ItemUpdates ohne Datenverlust Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fdd0ab163d
commit
5e7b1a9ec7
5 changed files with 570 additions and 48 deletions
105
app.css
105
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; }
|
||||
|
|
|
|||
294
app.js
294
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 = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="cam-close">✕</button>
|
||||
<div class="fs-title">📷 Kamera</div>
|
||||
<button class="icon-btn" id="cam-flip" title="Kamera wechseln">🔄</button>
|
||||
</div>
|
||||
<div class="cam-stage">
|
||||
<video id="cam-video" playsinline autoplay muted></video>
|
||||
<div class="cam-hint" id="cam-hint">Kamera wird gestartet…</div>
|
||||
</div>
|
||||
<div class="cam-filmstrip" id="cam-strip"></div>
|
||||
<div class="cam-controls">
|
||||
<div class="cam-count" id="cam-count"></div>
|
||||
<button class="cam-shutter" id="cam-shutter" aria-label="Auslösen"></button>
|
||||
<div class="cam-done-wrap"><button class="btn btn-small" id="cam-done">Fertig</button></div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<img src="' + url + '" alt=""><span class="cam-thumb-badge"></span>'
|
||||
+ '<button class="cam-thumb-del" title="Löschen">🗑</button>';
|
||||
// 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 = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="q-close">✕</button>
|
||||
<div class="fs-title">☁ Upload-Warteschlange</div>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="fs-body" style="flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px;color:#fff;">
|
||||
<p style="margin:0;">${pending.length} warten auf Upload${failed.length ? ', <strong>' + failed.length + ' fehlgeschlagen</strong>' : ''}.</p>
|
||||
<button class="btn" id="q-sync">🔄 Jetzt synchronisieren</button>
|
||||
${failed.length ? '<button class="btn btn-secondary" id="q-retry">↻ Fehlgeschlagene erneut senden</button>' : ''}
|
||||
${failed.length ? '<button class="btn btn-secondary" id="q-share">📤 Fehlgeschlagene sichern / teilen</button>' : ''}
|
||||
<p style="margin:0;font-size:12px;color:#aaa;">Fotos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.</p>
|
||||
</div>
|
||||
`;
|
||||
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)
|
||||
* ============================================================ */
|
||||
|
|
|
|||
50
lib/api.js
50
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) {
|
||||
|
|
|
|||
13
lib/idb.js
13
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 };
|
||||
})();
|
||||
|
|
|
|||
156
lib/offline.js
156
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 };
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue