PWA: Android-Zurück-Button-Fix + Datei-Teilen via Share-Sheet [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 5s

- 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) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-18 12:33:31 +02:00
parent 8be49c279f
commit 427fdeb0c0
2 changed files with 352 additions and 61 deletions

83
app.css
View file

@ -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;

330
app.js
View file

@ -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) => {
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen</button>
<div class="detail-section" style="margin-top:16px;">
<h3>Hochgeladene Fotos (${imagePhotos.length})</h3>
<div class="photo-section-head">
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
${imagePhotos.length ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
</div>
<div class="photo-grid" id="photo-grid">
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('')}
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div><div class="thumb-check">✓</div></div>`).join('')}
</div>
</div>
${audioFiles.length ? `
@ -444,11 +569,79 @@ router.on('/orders/:id', async (args) => {
</div>` : ''}
`;
// 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 = `
<button class="icon-btn" id="sb-cancel" title="Abbrechen"></button>
<div class="sb-info" id="sb-info">0 ausgewählt</div>
<button class="icon-btn" id="sb-all" title="Alle"></button>
<button class="icon-btn" id="sb-share" title="Teilen" disabled>📤</button>
`;
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 => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).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) => {
</div>
`;
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) {
</div>
`;
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) {
</div>
`;
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() {
</div>
`;
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) {
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="pdf-close"></button>
<div class="fs-title">📑 PDF-Vorschau</div>
<a class="icon-btn" id="pdf-download" href="${blobUrl}" download="bericht.pdf" title="Download"></a>
<button class="icon-btn" id="pdf-share" title="Teilen">📤</button>
<a class="icon-btn" id="pdf-download" href="${blobUrl}" download="${escapeHtml(fname)}" title="Download"></a>
</div>
<div class="fs-body" style="padding:0;">
<iframe src="${blobUrl}" style="width:100%;height:100%;border:none;background:#444;"></iframe>
</div>
`;
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) {
</div>
`;
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() {
</div>
`;
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) {
<div class="fs-header">
<button class="icon-btn" id="dv-close"></button>
<div class="fs-title">${escapeHtml(filename || '')}</div>
<button class="icon-btn" id="dv-share" title="Teilen">📤</button>
</div>
<div class="dv-body">
<img src="${url}" style="max-width:100%;max-height:100%;object-fit:contain;">
</div>
`;
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) {
<button class="icon-btn" id="pdf-next" title="Seite vor →"></button>
<div class="fs-title">${escapeHtml(filename || '')}</div>
<button class="icon-btn" id="pdf-close"></button>
<button class="icon-btn" id="pdf-share" title="Teilen">📤</button>
<button class="icon-btn" id="pdf-download" title="Download"></button>
</div>
<div class="pdf-body" id="pdf-body">
@ -1866,6 +2068,7 @@ async function openPdfViewer(blob, filename, relpath) {
</div>
`;
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) {
<button class="icon-btn" id="fs-close"></button>
<div class="fs-title" id="pv-title"></div>
<div class="pv-counter" id="pv-counter"></div>
<button class="icon-btn" id="fs-share" title="Teilen">📤</button>
<button class="icon-btn" id="fs-sketch" title="Zeichnen"></button>
<button class="icon-btn" id="fs-delete" title="Löschen">🗑</button>
</div>
@ -1958,6 +2165,7 @@ async function openPhotoModal(orderId, relpath) {
</div>
`;
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) {
</div>
`;
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');