diff --git a/app.css b/app.css index 2fa81d6..fb732e1 100644 --- a/app.css +++ b/app.css @@ -439,6 +439,58 @@ body { .btn[disabled] { opacity: 0.5; cursor: not-allowed; } +/* Report-Page-Thumb mit Nummer */ +.report-page-thumb { position: relative; } +.report-page-thumb .page-num { + position: absolute; + top: 4px; left: 4px; + background: rgba(0,0,0,0.7); + color: #fff; + font-size: 11px; + padding: 2px 6px; + border-radius: 10px; + font-weight: 600; +} + +/* Unterschrift-Modal */ +.signature-modal .signature-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: #1a1a1f; + border-bottom: 1px solid #333; +} +.signature-modal .signature-toolbar button { + background: #2a2a30; + color: #fff; + border: 1px solid #444; + border-radius: 6px; + padding: 8px 14px; + cursor: pointer; +} +.signature-modal .sig-hint { + opacity: 0.6; + font-size: 13px; +} +.signature-modal .signature-body { + flex: 1; + background: #222; + padding: 16px; + display: flex; + align-items: center; + justify-content: center; +} +.signature-modal canvas { + background: #fff; + width: 100%; + max-width: 900px; + aspect-ratio: 2 / 1; + border-radius: 6px; + touch-action: none; + box-shadow: 0 0 20px rgba(0,0,0,0.5); +} + /* Hilfe-Modal */ .help-modal .help-body { flex: 1; diff --git a/app.js b/app.js index 8a913cc..5914ade 100644 --- a/app.js +++ b/app.js @@ -367,15 +367,39 @@ router.on('/reports/:id', async (args) => { ${hasPages ? `
- ${data.pages.map(p => `
`).join('')} + ${data.pages.map((p, i) => ` +
+
+
${i + 1}
+
+ `).join('')}
` : '
📭
Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.
'} + ${hasPages ? '' : ''} + `; loadThumbs(); + // Tap auf Report-Page-Thumb → Seiten-Aktionen-Modal + document.querySelectorAll('.report-page-thumb').forEach(t => { + t.addEventListener('click', () => openPageActionsModal(args.id, t.dataset.pageId, t.dataset.relpath, t.dataset.note || '')); + }); + + // PDF-Vorschau + const pdfBtn = document.getElementById('btn-view-pdf'); + if (pdfBtn) pdfBtn.onclick = async () => { + showToast('PDF wird geladen…'); + const url = await api.getPdfBlobUrl(args.id); + if (!url) { showToast('PDF konnte nicht geladen werden', 'error'); return; } + openPdfModal(url); + }; + + // Unterschrift + document.getElementById('btn-signature').onclick = () => openSignatureModal(args.id); + const finalizeBtn = document.getElementById('btn-finalize'); if (finalizeBtn) { finalizeBtn.onclick = async () => { @@ -437,6 +461,150 @@ document.addEventListener('DOMContentLoaded', () => { if (helpBtn) helpBtn.addEventListener('click', openHelpModal); }); +/* ============================================================ + * SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild) + * ============================================================ */ +async function openPageActionsModal(berichtId, pageId, relpath, note) { + const url = await api.getPhotoBlobUrl(relpath); + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal'; + modal.innerHTML = ` +
+ +
Seite bearbeiten
+ +
+
+ ${url ? `` : '
'} + + + +
+ `; + document.body.appendChild(modal); + + modal.querySelector('#pa-close').onclick = () => modal.remove(); + modal.querySelector('#pa-save-note').onclick = async () => { + try { + await api.updatePageNote(pageId, modal.querySelector('#pa-note').value); + showToast('✓ Notiz gespeichert'); + modal.remove(); + router.navigate(); + } catch (e) { showToast('Fehler: ' + e.message, 'error'); } + }; + modal.querySelector('#pa-delete').onclick = async () => { + if (!confirm('Diese Seite aus dem Bericht entfernen?')) return; + try { + await api.deletePage(pageId); + showToast('✓ Seite entfernt'); + modal.remove(); + router.navigate(); + } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } + }; +} + +/* ============================================================ + * PDF-VORSCHAU MODAL + * ============================================================ */ +function openPdfModal(blobUrl) { + 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(); + }; +} + +/* ============================================================ + * UNTERSCHRIFT MODAL (Touch-Signatur) + * ============================================================ */ +function openSignatureModal(berichtId) { + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal signature-modal'; + modal.innerHTML = ` +
+ +
✍️ Kunden-Unterschrift
+ +
+
+ + Mit dem Finger unterschreiben +
+
+ +
+ `; + document.body.appendChild(modal); + + const canvas = modal.querySelector('#sig-canvas'); + const ctx = canvas.getContext('2d'); + + function fitCanvas() { + const body = modal.querySelector('.signature-body'); + const rect = body.getBoundingClientRect(); + canvas.width = Math.max(600, Math.floor(rect.width * 2)); + canvas.height = Math.max(300, Math.floor(rect.height * 2)); + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = '#000'; + ctx.lineWidth = 4; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + } + fitCanvas(); + window.addEventListener('resize', fitCanvas); + + let drawing = false; + function pos(e) { + const r = canvas.getBoundingClientRect(); + const sx = canvas.width / r.width; + const sy = canvas.height / r.height; + const t = e.touches ? e.touches[0] : e; + return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; + } + function start(e) { e.preventDefault(); drawing = true; const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); } + function move(e) { if (!drawing) return; e.preventDefault(); const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); } + function end() { drawing = false; } + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + canvas.addEventListener('mouseup', end); + canvas.addEventListener('mouseleave', end); + canvas.addEventListener('touchstart', start, { passive: false }); + canvas.addEventListener('touchmove', move, { passive: false }); + canvas.addEventListener('touchend', end); + + modal.querySelector('#sig-clear').onclick = fitCanvas; + modal.querySelector('#sig-close').onclick = () => { + window.removeEventListener('resize', fitCanvas); + modal.remove(); + }; + modal.querySelector('#sig-save').onclick = () => { + showToast('Speichere Unterschrift…'); + canvas.toBlob(async (blob) => { + try { + await api.uploadSignature(berichtId, blob); + showToast('✓ Unterschrift hinzugefügt'); + modal.remove(); + router.navigate(); + } catch (e) { showToast('Fehler: ' + e.message, 'error'); } + }, 'image/png'); + }; +} + function openHelpModal() { const modal = document.createElement('div'); modal.className = 'fullscreen-modal help-modal'; diff --git a/lib/api.js b/lib/api.js index 079c1ac..8c24fe1 100644 --- a/lib/api.js +++ b/lib/api.js @@ -110,6 +110,36 @@ return uploadOrderPhoto(orderId, fileBlob, filename); } + async function deletePage(pageId) { + return request('/pages.php?id=' + pageId, { method: 'DELETE' }); + } + + async function updatePageNote(pageId, note) { + return request('/pages.php?id=' + pageId, { + method: 'POST', + body: JSON.stringify({ note }), + }); + } + + async function uploadSignature(berichtId, pngBlob) { + const fd = new FormData(); + fd.append('file', pngBlob, 'signature.png'); + return request('/pages.php?action=signature&bericht_id=' + berichtId, { + method: 'POST', + body: fd, + }); + } + + async function getPdfBlobUrl(berichtId) { + const t = await getToken(); + if (!t) return null; + const params = new URLSearchParams({ id: berichtId, jwt: t }); + const r = await fetch(API_BASE + '/pdf.php?' + params.toString()); + if (!r.ok) return null; + const blob = await r.blob(); + return URL.createObjectURL(blob); + } + /** * Lädt eine Bild-Datei von der API als Blob-URL (inkl. JWT). * Wird benötigt weil keine Authorization-Header mitschickt. @@ -155,5 +185,6 @@ getReport, listReports, finalizeReport, deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto, getPhotoBlobUrl, clearPhotoCache, + deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, }; })(); diff --git a/manifest.webmanifest b/manifest.webmanifest index 529d06e..4813f86 100644 --- a/manifest.webmanifest +++ b/manifest.webmanifest @@ -12,5 +12,17 @@ "icons": [ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } - ] + ], + "share_target": { + "action": "./share.html", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "files": [ + { "name": "photos", "accept": ["image/jpeg", "image/png", "image/webp"] } + ] + } + } } diff --git a/share.html b/share.html new file mode 100644 index 0000000..eb2c8b7 --- /dev/null +++ b/share.html @@ -0,0 +1,82 @@ + + + + + +Foto teilen — Baustelle + + + +
+
+ +

📤 Foto teilen

+ +
+
+
+
Foto wird empfangen…
+
+
+
+ + + + + + diff --git a/sw.js b/sw.js index 46306a1..9f79708 100644 --- a/sw.js +++ b/sw.js @@ -4,10 +4,11 @@ * - API-Calls: network-first, kein offline-cache (da auth-pflichtig) */ -const CACHE = 'baustelle-v4'; +const CACHE = 'baustelle-v5'; const SHELL = [ './', './index.html', + './share.html', './app.css', './app.js', './manifest.webmanifest', @@ -19,6 +20,28 @@ const SHELL = [ './icons/icon-512.png', ]; +// Web Share Target: eingehende POSTs an share.html abfangen und in IDB zwischenspeichern +async function handleShareTarget(request) { + const fd = await request.formData(); + const files = fd.getAll('photos'); + if (files.length) { + const db = await new Promise((res, rej) => { + const req = indexedDB.open('baustelle-pwa-v1', 1); + req.onupgradeneeded = () => { + const d = req.result; + if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv'); + if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true }); + }; + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); + }); + const tx = db.transaction('kv', 'readwrite'); + tx.objectStore('kv').put(files.map(f => ({ name: f.name, type: f.type, data: f })), 'shared_files'); + await new Promise(res => tx.oncomplete = res); + } + return Response.redirect('./share.html', 303); +} + self.addEventListener('install', (e) => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL).catch(() => null))); self.skipWaiting(); @@ -36,6 +59,12 @@ self.addEventListener('activate', (e) => { self.addEventListener('fetch', (e) => { const url = new URL(e.request.url); + // Web Share Target: POST auf share.html abfangen + if (e.request.method === 'POST' && url.pathname.endsWith('/share.html')) { + e.respondWith(handleShareTarget(e.request)); + return; + } + // API-Requests: nicht cachen, durchreichen if (url.pathname.includes('/custom/bericht/api/')) { return; // default network