From 427fdeb0c07bb321f3e22d283510325f594bbd2a Mon Sep 17 00:00:00 2001 From: Eddy Date: Sat, 18 Apr 2026 12:33:31 +0200 Subject: [PATCH] =?UTF-8?q?PWA:=20Android-Zur=C3=BCck-Button-Fix=20+=20Dat?= =?UTF-8?q?ei-Teilen=20via=20Share-Sheet=20[deploy]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modal-Stack mit History-API: jedes Modal pusht einen History-Eintrag, Android-Zurück schließt das Modal statt die App zu beenden - Top-Level-Routen: "Nochmal drücken zum Beenden"-Toast (2s Fenster) - shareFile()/shareFiles(): native Android-Share-Sheet für Fotos, PDFs, Dokumente - Multi-Select in Fotogalerie: Mehrere Fotos gleichzeitig auswählen und teilen - PDF-Teilen: auto-.pdf-Extension damit Android-Apps die Datei akzeptieren - openPdfModal: Share-Button neben Download-Button Co-Authored-By: Claude Opus 4.7 (1M context) --- app.css | 83 +++++++++++++- app.js | 330 ++++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 352 insertions(+), 61 deletions(-) diff --git a/app.css b/app.css index a8abce9..c19d428 100644 --- a/app.css +++ b/app.css @@ -307,6 +307,78 @@ body { opacity: 0.5; } +/* Foto-Section Kopfzeile mit Auswahl-Toggle */ +.photo-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 0 0 6px; +} +.btn-small { + padding: 6px 10px; + font-size: 13px; + border-radius: 6px; + background: #2a2a30; + color: #eee; + border: 1px solid #444; + cursor: pointer; +} +.btn-small:hover { background: #33333a; } + +/* Auswahl-Mode im Photo-Grid */ +.photo-grid .thumb .thumb-check { + position: absolute; + top: 6px; + right: 6px; + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(0,0,0,0.55); + color: #fff; + display: none; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: bold; + border: 2px solid #fff; + pointer-events: none; +} +.photo-grid.selecting .thumb-check { display: flex; opacity: 0.35; } +.photo-grid.selecting .thumb.selected { outline: 3px solid #5ab0ff; outline-offset: -3px; } +.photo-grid.selecting .thumb.selected .thumb-check { + opacity: 1; + background: #5ab0ff; + border-color: #fff; +} + +/* Sticky Aktions-Bar am unteren Bildschirmrand */ +.select-bar { + position: fixed; + left: 0; right: 0; + bottom: 0; + padding: 10px 14px; + padding-bottom: calc(10px + env(safe-area-inset-bottom)); + background: rgba(20,20,25,0.95); + border-top: 1px solid #333; + display: flex; + align-items: center; + gap: 12px; + z-index: 90; + backdrop-filter: blur(6px); +} +.select-bar .sb-info { + flex: 1; + color: #eee; + font-size: 14px; +} +.select-bar .icon-btn { + color: #fff; + font-size: 20px; + padding: 6px 10px; +} +.select-bar .icon-btn:disabled { opacity: 0.35; cursor: default; } + /* ----- Toast ----- */ #toast-container { position: fixed; @@ -380,11 +452,12 @@ body { .fs-header { display: flex; align-items: center; - gap: 12px; + gap: 8px; padding: 10px 12px; background: rgba(0,0,0,0.7); color: #fff; border-bottom: 1px solid #222; + flex-wrap: wrap; } .fs-header .fs-title { flex: 1; @@ -609,15 +682,17 @@ body { .pdf-viewer-modal .fs-header { display: flex; align-items: center; - gap: 8px; + gap: 6px; flex-wrap: wrap; } .pdf-viewer-modal .fs-title { order: 4; } .pdf-viewer-modal .icon-btn:nth-of-type(1) { order: 1; } .pdf-viewer-modal .pdf-page-info { order: 2; font-size: 12px; color: #aaa; } .pdf-viewer-modal .icon-btn:nth-of-type(2) { order: 3; } -.pdf-viewer-modal .icon-btn:nth-of-type(3) { order: 5; } -.pdf-viewer-modal .fs-header a { order: 6; } +.pdf-viewer-modal .icon-btn:nth-of-type(3) { order: 5; } /* close */ +.pdf-viewer-modal .icon-btn:nth-of-type(4) { order: 6; } /* share */ +.pdf-viewer-modal .icon-btn:nth-of-type(5) { order: 7; } /* download */ +.pdf-viewer-modal .fs-header a { order: 7; } .pdf-body { flex: 1; display: flex; diff --git a/app.js b/app.js index 9f717e7..8f71bdb 100644 --- a/app.js +++ b/app.js @@ -29,7 +29,117 @@ function setNav(visible, active) { function setBack(visible, hash) { const btn = document.getElementById('back-btn'); btn.style.display = visible ? '' : 'none'; - btn.onclick = () => { if (hash) router.go(hash); else history.back(); }; + // Immer history.back() — offene Modals werden vom popstate-Handler geschlossen, + // Hash-Routen per router.go() liegen als History-Einträge vor. + btn.onclick = () => history.back(); +} + +/* ============================================================ + * Modal-Stack: Android-Back schließt oberstes Modal statt die App + * Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag. + * popstate (Android-Back) pop-t den Stack und entfernt das Modal. + * Programmatisches Schließen (closeModal) räumt synchron auf und + * setzt ein Skip-Flag, damit der eigene history.back() kein + * doppeltes Cleanup auslöst. + * ============================================================ */ +const modalStack = []; +let _skipNextPopstate = false; +let _lastBackAt = 0; + +function pushModal(el, cleanup) { + modalStack.push({ el, cleanup: cleanup || null }); + try { + history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash); + } catch (e) { /* some browsers may block in strict sandboxes */ } +} + +function closeModal(el) { + const idx = modalStack.findIndex(m => m.el === el); + if (idx === -1) { + try { el.remove(); } catch {} + return; + } + const entry = modalStack[idx]; + modalStack.splice(idx, 1); + try { entry.el.remove(); } catch {} + try { entry.cleanup && entry.cleanup(); } catch {} + // Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion + if (history.state && history.state._modal) { + _skipNextPopstate = true; + history.back(); + } +} + +function isTopLevelHash(h) { + const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/'; + return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh); +} + +window.addEventListener('popstate', () => { + if (_skipNextPopstate) { _skipNextPopstate = false; return; } + if (modalStack.length > 0) { + const top = modalStack.pop(); + try { top.el.remove(); } catch {} + try { top.cleanup && top.cleanup(); } catch {} + return; + } + if (isTopLevelHash(location.hash)) { + const now = Date.now(); + if (now - _lastBackAt < 2000) return; + _lastBackAt = now; + try { history.pushState({}, '', location.hash); } catch {} + showToast('Nochmal drücken zum Beenden'); + } +}); + +/* ============================================================ + * Share-Helper: teilt eine oder mehrere Dateien über Web Share API. + * shareFile(blob, filename, mime, title) — einzelne Datei + * shareFiles([{blob,filename,mime}, …], title) — mehrere Dateien + * Fällt auf Toast zurück, wenn das Gerät kein Datei-Sharing unterstützt. + * ============================================================ */ +function _buildShareFile(blob, filename, mime) { + const fname = filename || 'datei'; + // Name-Endung am MIME ausrichten, sonst lehnen manche Android-Apps ab + let safeName = fname; + const m = (mime || blob.type || '').toLowerCase(); + if (m === 'application/pdf' && !/\.pdf$/i.test(safeName)) safeName += '.pdf'; + return new File([blob], safeName, { type: mime || blob.type || 'application/octet-stream' }); +} + +async function shareFiles(items, titleHint) { + const files = items.map(it => _buildShareFile(it.blob, it.filename, it.mime)); + try { + if (!navigator.canShare || !navigator.share) { + showToast('Teilen wird vom Browser nicht unterstützt', 'error'); + return false; + } + if (!navigator.canShare({ files })) { + // Versuche einzeln — manche Geräte lehnen Batches bestimmter MIMEs ab + if (files.length > 1) { + showToast('Batch-Teilen wird nicht unterstützt — sende einzeln', 'error'); + return false; + } + showToast('Dieser Dateityp kann nicht geteilt werden', 'error'); + return false; + } + await navigator.share({ + files, + title: titleHint || (files.length === 1 ? files[0].name : files.length + ' Dateien'), + text: titleHint || '', + }); + return true; + } catch (e) { + if (e && e.name !== 'AbortError') { + console.warn('[Share]', e); + showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); + } + return false; + } +} + +async function shareFile(blob, filename, mime, titleHint) { + return shareFiles([{ blob, filename, mime }], titleHint); } /* ----- Auth-Check ----- */ @@ -135,7 +245,7 @@ function promptPin(title) { current += d; render(); if (current.length === 4) { - setTimeout(() => { modal.remove(); resolve(current); }, 150); + setTimeout(() => { closeModal(modal); resolve(current); }, 150); } } function del() { @@ -156,9 +266,12 @@ function promptPin(title) { const cancel = document.createElement('button'); cancel.className = 'pin-cancel'; cancel.textContent = 'Abbrechen'; - cancel.onclick = () => { modal.remove(); resolve(null); }; + cancel.onclick = () => { closeModal(modal); resolve(null); }; modal.querySelector('.pin-body').appendChild(cancel); document.body.appendChild(modal); + // Android-Back schließt Modal → Promise mit null auflösen + // (doppeltes resolve ist per JS-Spec no-op, falls PIN bereits eingegeben war) + pushModal(modal, () => resolve(null)); }); } async function promptNewPin() { @@ -186,6 +299,15 @@ window.appBoot = async function appBoot() { console.warn('[boot] Token-Preload fehlgeschlagen', e); } + // Bootstrap-Puffer: legt einen zusätzlichen History-Eintrag an, damit beim + // ersten Android-Back der popstate-Handler greifen kann (Toast „Nochmal + // drücken zum Beenden"), bevor die PWA wirklich verlassen wird. + try { + if (!history.state || !history.state._bootstrap) { + history.pushState({ _bootstrap: true }, '', location.hash || '#/orders'); + } + } catch {} + // FAB: Click öffnet das Schnell-Auftrag-Modal const fab = document.getElementById('fab-new-order'); if (fab && !fab.dataset.bound) { @@ -411,9 +533,12 @@ router.on('/orders/:id', async (args) => {
-

Hochgeladene Fotos (${imagePhotos.length})

+
+

Hochgeladene Fotos (${imagePhotos.length})

+ ${imagePhotos.length ? '' : ''} +
- ${imagePhotos.map(p => `
`).join('')} + ${imagePhotos.map(p => `
`).join('')}
${audioFiles.length ? ` @@ -444,11 +569,79 @@ router.on('/orders/:id', async (args) => { ` : ''} `; - // Foto-Thumbnails: Tap = Vollbild-Modal - document.querySelectorAll('#photo-grid .thumb').forEach(t => { - t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath)); + // Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus) + const photoGrid = document.getElementById('photo-grid'); + photoGrid.querySelectorAll('.thumb').forEach(t => { + t.addEventListener('click', () => { + if (photoGrid.classList.contains('selecting')) { + t.classList.toggle('selected'); + updateSelectBar(); + } else { + openPhotoModal(args.id, t.dataset.relpath); + } + }); }); + // Mehrfachauswahl-Modus + const selectBtn = document.getElementById('btn-select-mode'); + if (selectBtn) selectBtn.onclick = () => startSelectMode(); + + function startSelectMode() { + photoGrid.classList.add('selecting'); + renderSelectBar(); + updateSelectBar(); + } + function endSelectMode() { + photoGrid.classList.remove('selecting'); + photoGrid.querySelectorAll('.thumb.selected').forEach(x => x.classList.remove('selected')); + const bar = document.getElementById('select-bar'); + if (bar) bar.remove(); + } + function renderSelectBar() { + if (document.getElementById('select-bar')) return; + const bar = document.createElement('div'); + bar.id = 'select-bar'; + bar.className = 'select-bar'; + bar.innerHTML = ` + +
0 ausgewählt
+ + + `; + document.body.appendChild(bar); + bar.querySelector('#sb-cancel').onclick = endSelectMode; + bar.querySelector('#sb-all').onclick = () => { + const thumbs = photoGrid.querySelectorAll('.thumb[data-relpath]'); + const anyUn = Array.from(thumbs).some(t => !t.classList.contains('selected')); + thumbs.forEach(t => t.classList.toggle('selected', anyUn)); + updateSelectBar(); + }; + bar.querySelector('#sb-share').onclick = () => shareSelected(); + } + function updateSelectBar() { + const n = photoGrid.querySelectorAll('.thumb.selected').length; + const info = document.getElementById('sb-info'); + const share = document.getElementById('sb-share'); + if (info) info.textContent = n + ' ausgewählt'; + if (share) share.disabled = n === 0; + } + async function shareSelected() { + const sel = Array.from(photoGrid.querySelectorAll('.thumb.selected')); + if (!sel.length) return; + showToast('Lade ' + sel.length + ' Foto' + (sel.length === 1 ? '' : 's') + '…'); + const items = []; + for (const t of sel) { + const rp = t.dataset.relpath; + try { + const f = await api.getFileBlobUrl(rp); + if (f) items.push({ blob: f.blob, filename: rp.split('/').pop(), mime: f.mime }); + } catch (e) { console.warn('Share-Load', rp, e); } + } + if (!items.length) { showToast('Keine Dateien geladen', 'error'); return; } + const ok = await shareFiles(items, 'Fotos vom Auftrag'); + if (ok) endSelectMode(); + } + // Weitere Dokumente: Tap = Öffnen (PDF inline, sonst Download) document.querySelectorAll('.doc-list .doc-item').forEach(el => { el.addEventListener('click', async () => { @@ -544,14 +737,9 @@ router.on('/orders/:id', async (args) => { for (const f of files) { await uploadPhoto(args.id, f); } - // Reload Photo-Liste - try { - const np = await api.listOrderPhotos(args.id); - const imgs = np.photos.filter(p => (p.mime || '').startsWith('image/')); - document.getElementById('photo-grid').innerHTML = - imgs.map(p => `
`).join(''); - loadThumbs(); - } catch (e) {} + // Nach Upload einfach die Route neu rendern — so werden Select-Mode, + // Click-Handler und Thumbnails sauber neu aufgebaut. + router.navigate(); } camInput.addEventListener('change', () => handleFiles(camInput.files)); galInput.addEventListener('change', () => handleFiles(galInput.files)); @@ -836,7 +1024,8 @@ router.on('/reports/:id', async (args) => { showToast('PDF wird geladen…'); const url = await api.getPdfBlobUrl(args.id); if (!url) { showToast('PDF konnte nicht geladen werden', 'error'); return; } - openPdfModal(url); + const fname = (data.report && data.report.ref ? data.report.ref : ('bericht-' + args.id)) + '.pdf'; + openPdfModal(url, fname); }; // Unterschrift @@ -881,12 +1070,13 @@ router.on('/reports/:id', async (args) => { `; document.body.appendChild(modal); - modal.querySelector('#dr-cancel').onclick = () => modal.remove(); - modal.querySelector('#dr-no').onclick = () => modal.remove(); + pushModal(modal); + modal.querySelector('#dr-cancel').onclick = () => closeModal(modal); + modal.querySelector('#dr-no').onclick = () => closeModal(modal); modal.querySelector('#dr-yes').onclick = async () => { try { await api.deleteReport(args.id); - modal.remove(); + closeModal(modal); showToast('Bericht gelöscht'); router.go('#/reports'); } catch (e) { @@ -1141,8 +1331,9 @@ async function openMaterialModal(elementType, elementId) { `; document.body.appendChild(modal); + pushModal(modal); - modal.querySelector('#mt-close').onclick = () => modal.remove(); + modal.querySelector('#mt-close').onclick = () => closeModal(modal); async function reload() { try { @@ -1244,6 +1435,7 @@ async function openNewReportModal(orderId) { `; document.body.appendChild(modal); + pushModal(modal); // ODT-Default vorauswählen try { @@ -1254,7 +1446,7 @@ async function openNewReportModal(orderId) { } } catch (e) {} - modal.querySelector('#nr-close').onclick = () => modal.remove(); + modal.querySelector('#nr-close').onclick = () => closeModal(modal); modal.querySelector('#nr-save').onclick = async () => { const titel = modal.querySelector('#nr-titel').value.trim(); const tplSel = modal.querySelector('#nr-template'); @@ -1275,7 +1467,7 @@ async function openNewReportModal(orderId) { template_odt: odt, }); showToast('✓ Bericht angelegt'); - modal.remove(); + closeModal(modal); router.go('#/reports/' + res.bericht_id); } catch (e) { showToast('Fehler: ' + e.message, 'error'); @@ -1323,6 +1515,7 @@ async function openNewOrderModal() { `; document.body.appendChild(modal); + pushModal(modal); const closeBtn = modal.querySelector('#no-close'); const saveBtn = modal.querySelector('#no-save'); @@ -1339,7 +1532,7 @@ async function openNewOrderModal() { let selectedCustomer = null; - closeBtn.onclick = () => modal.remove(); + closeBtn.onclick = () => closeModal(modal); /* ---- Letzte Kunden als Quick-Pick ---- */ async function renderRecent() { @@ -1451,7 +1644,7 @@ async function openNewOrderModal() { } catch (e) {} showToast('✓ Auftrag ' + (res.order.ref || '') + ' angelegt'); - modal.remove(); + closeModal(modal); router.go('#/orders/' + res.order.id); } catch (e) { showToast('Fehler: ' + e.message, 'error'); @@ -1483,8 +1676,9 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) { `; document.body.appendChild(modal); + pushModal(modal); - modal.querySelector('#pa-close').onclick = () => modal.remove(); + modal.querySelector('#pa-close').onclick = () => closeModal(modal); modal.querySelector('#pa-save').onclick = async () => { try { const note = modal.querySelector('#pa-note').value; @@ -1494,14 +1688,14 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) { body: JSON.stringify({ note, title }), }); showToast('✓ Gespeichert'); - modal.remove(); + closeModal(modal); router.navigate(); } catch (e) { // Fallback falls api.request nicht exposed ist try { await api.updatePageNote(pageId, modal.querySelector('#pa-note').value); showToast('✓ Notiz gespeichert'); - modal.remove(); + closeModal(modal); router.navigate(); } catch (er) { showToast('Fehler: ' + er.message, 'error'); } } @@ -1511,7 +1705,7 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) { try { await api.deletePage(pageId); showToast('✓ Seite entfernt'); - modal.remove(); + closeModal(modal); router.navigate(); } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; @@ -1520,23 +1714,30 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) { /* ============================================================ * PDF-VORSCHAU MODAL * ============================================================ */ -function openPdfModal(blobUrl) { +function openPdfModal(blobUrl, filename) { + const fname = filename || 'bericht.pdf'; const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
📑 PDF-Vorschau
- + +
`; document.body.appendChild(modal); - modal.querySelector('#pdf-close').onclick = () => { - URL.revokeObjectURL(blobUrl); - modal.remove(); + pushModal(modal, () => { try { URL.revokeObjectURL(blobUrl); } catch {} }); + modal.querySelector('#pdf-close').onclick = () => closeModal(modal); + modal.querySelector('#pdf-share').onclick = async () => { + try { + const r = await fetch(blobUrl); + const blob = await r.blob(); + await shareFile(blob, fname, 'application/pdf', fname); + } catch (e) { showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); } }; } @@ -1569,6 +1770,7 @@ function openSignatureModal(berichtId) { `; document.body.appendChild(modal); + pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvas); } catch {} }); const canvas = modal.querySelector('#sig-canvas'); const ctx = canvas.getContext('2d'); @@ -1610,10 +1812,7 @@ function openSignatureModal(berichtId) { canvas.addEventListener('touchend', end); modal.querySelector('#sig-clear').onclick = fitCanvas; - modal.querySelector('#sig-close').onclick = () => { - window.removeEventListener('resize', fitCanvas); - modal.remove(); - }; + modal.querySelector('#sig-close').onclick = () => closeModal(modal); modal.querySelector('#sig-save').onclick = async () => { const signer = modal.querySelector('#sig-name').value.trim(); if (!signer) { @@ -1641,7 +1840,7 @@ function openSignatureModal(berichtId) { if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; } await api.uploadSignature(berichtId, blob, opts); showToast('✓ Unterschrift hinzugefügt'); - modal.remove(); + closeModal(modal); router.navigate(); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }, 'image/png'); @@ -1761,7 +1960,8 @@ function openHelpModal() { `; document.body.appendChild(modal); - modal.querySelector('#help-close').onclick = () => modal.remove(); + pushModal(modal); + modal.querySelector('#help-close').onclick = () => closeModal(modal); } function escapeHtml(s) { @@ -1829,17 +2029,18 @@ function openFileViewer({ url, blob, mime }, filename, relpath) {
${escapeHtml(filename || '')}
+
`; document.body.appendChild(modal); - const cleanup = () => { - URL.revokeObjectURL(url); - modal.remove(); + pushModal(modal, () => { try { URL.revokeObjectURL(url); } catch {} }); + modal.querySelector('#dv-close').onclick = () => closeModal(modal); + modal.querySelector('#dv-share').onclick = () => { + shareFile(blob, filename || 'datei', mime || blob.type, filename); }; - modal.querySelector('#dv-close').onclick = cleanup; } async function openPdfViewer(blob, filename, relpath) { @@ -1859,6 +2060,7 @@ async function openPdfViewer(blob, filename, relpath) {
${escapeHtml(filename || '')}
+
@@ -1866,6 +2068,7 @@ async function openPdfViewer(blob, filename, relpath) {
`; document.body.appendChild(modal); + pushModal(modal); const canvas = modal.querySelector('#pdf-canvas'); const ctx = canvas.getContext('2d'); @@ -1908,6 +2111,9 @@ async function openPdfViewer(blob, filename, relpath) { const params = new URLSearchParams({ relpath, jwt: t, download: 1 }); window.location.href = window.location.origin + '/custom/bericht/api/photo.php?' + params.toString(); }; + modal.querySelector('#pdf-share').onclick = () => { + shareFile(blob, filename || 'dokument.pdf', 'application/pdf', filename); + }; modal.querySelector('#pdf-prev').disabled = false; modal.querySelector('#pdf-next').disabled = pdf.numPages === 1; } catch (err) { @@ -1917,7 +2123,7 @@ async function openPdfViewer(blob, filename, relpath) { } modal.querySelector('#pdf-close').onclick = () => { - modal.remove(); + closeModal(modal); }; } @@ -1943,6 +2149,7 @@ async function openPhotoModal(orderId, relpath) {
+ @@ -1958,6 +2165,7 @@ async function openPhotoModal(orderId, relpath) { `; document.body.appendChild(modal); + pushModal(modal, () => { try { document.removeEventListener('keydown', keyHandler); } catch {} }); const img = modal.querySelector('#pv-image'); const body = modal.querySelector('#pv-body'); @@ -2002,7 +2210,7 @@ async function openPhotoModal(orderId, relpath) { resetZoom(); } - function close() { modal.remove(); document.removeEventListener('keydown', keyHandler); } + function close() { closeModal(modal); } function goPrev() { if (currentIndex > 0) loadImage(currentIndex - 1); } function goNext() { if (currentIndex < allRelpaths.length - 1) loadImage(currentIndex + 1); } @@ -2027,6 +2235,15 @@ async function openPhotoModal(orderId, relpath) { close(); openSketchEditor(orderId, currentUrl, allRelpaths[currentIndex]); }; + modal.querySelector('#fs-share').onclick = async () => { + const rp = allRelpaths[currentIndex]; + showToast('Lade Foto…'); + try { + const f = await api.getFileBlobUrl(rp); + if (!f) { showToast('Foto konnte nicht geladen werden', 'error'); return; } + await shareFile(f.blob, rp.split('/').pop(), f.mime, 'Foto vom Auftrag'); + } catch (e) { showToast('Fehler: ' + e.message, 'error'); } + }; // Doppelklick = Zoom Toggle img.addEventListener('dblclick', (e) => { @@ -2129,6 +2346,11 @@ async function openVoiceModal(orderId) { let startTime = 0; let audioBlob = null; + pushModal(modal, () => { + try { if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); } catch {} + try { if (timer) clearInterval(timer); } catch {} + }); + const startBtn = modal.querySelector('#v-start'); const stopBtn = modal.querySelector('#v-stop'); const sendBtn = modal.querySelector('#v-send'); @@ -2136,11 +2358,7 @@ async function openVoiceModal(orderId) { const timeEl = modal.querySelector('#v-time'); const preview = modal.querySelector('#v-preview'); - modal.querySelector('#v-close').onclick = () => { - if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); - if (timer) clearInterval(timer); - modal.remove(); - }; + modal.querySelector('#v-close').onclick = () => closeModal(modal); startBtn.onclick = async () => { try { @@ -2182,7 +2400,7 @@ async function openVoiceModal(orderId) { try { await api.uploadVoiceNote(orderId, audioBlob, 'voice_' + Date.now() + '.webm'); showToast('✓ Sprachnotiz hochgeladen'); - modal.remove(); + closeModal(modal); } catch (e) { showToast('Upload fehlgeschlagen: ' + e.message, 'error'); sendBtn.disabled = false; @@ -2227,6 +2445,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { `; document.body.appendChild(modal); + pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvasToScreen); } catch {} }); const canvas = modal.querySelector('#sk-canvas'); const ctx = canvas.getContext('2d'); @@ -2528,10 +2747,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { canvas.addEventListener('touchmove', moveDraw, { passive: false }); canvas.addEventListener('touchend', endDraw); - modal.querySelector('#sk-close').onclick = () => { - window.removeEventListener('resize', fitCanvasToScreen); - modal.remove(); - }; + modal.querySelector('#sk-close').onclick = () => closeModal(modal); modal.querySelector('#sk-save').onclick = async () => { showToast('Speichere Skizze…'); @@ -2539,7 +2755,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { try { await api.uploadAnnotatedPhoto(orderId, blob, 'sketch_' + Date.now() + '.jpg'); showToast('✓ Skizze gespeichert'); - modal.remove(); + closeModal(modal); router.navigate(); } catch (e) { showToast('Upload fehlgeschlagen: ' + e.message, 'error');