feat: Phase 4 Block 1 — Seite bearbeiten, PDF-Vorschau, Unterschrift, Share Target
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s

Bericht-Detail-Ansicht:
- Tap auf Seiten-Thumb → openPageActionsModal mit:
  - Vorschaubild
  - Textarea für Seiten-Notiz (wird im PDF unter der Seite gedruckt)
  - '💾 Notiz speichern' → api.updatePageNote
  - 🗑️ im Header → api.deletePage mit Confirm
- Seiten-Thumbs haben Nummer-Badge oben links (1, 2, 3…)
- Neuer '👁 PDF-Vorschau'-Button öffnet openPdfModal (iframe mit
  Blob-URL) für Final-PDF oder on-the-fly Preview
- Neuer '✍️ Kunden-Unterschrift hinzufügen'-Button öffnet
  openSignatureModal: Touch-Canvas 2:1, Clear, Save → PNG wird als
  neue Bericht-Seite mit note='Unterschrift Kunde' angelegt

Web Share Target API:
- manifest.webmanifest: share_target mit photos array
- share.html: empfängt geteilte Fotos aus IDB (vom SW befüllt),
  zeigt Auftragsliste, Tap → Upload aller Fotos
- Service Worker v5: fängt POST /share.html ab, schreibt Files in
  IDB Key 'shared_files', redirected 303

Cache-Version bumpt damit neue Files geladen werden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 01:00:24 +02:00
parent 3f1b462105
commit 02ffdca57c
6 changed files with 377 additions and 3 deletions

52
app.css
View file

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

170
app.js
View file

@ -367,15 +367,39 @@ router.on('/reports/:id', async (args) => {
${hasPages ? `
<div class="photo-grid" id="report-pages">
${data.pages.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}"><div class="thumb-placeholder">⏳</div></div>`).join('')}
${data.pages.map((p, i) => `
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}">
<div class="thumb-placeholder"></div>
<div class="page-num">${i + 1}</div>
</div>
`).join('')}
</div>` : '<div class="empty-state"><div class="icon">📭</div>Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.</div>'}
<button class="btn btn-large" id="btn-finalize" ${hasPages ? '' : 'disabled'}>${finalizeLabel}</button>
${hasPages ? '<button class="btn btn-secondary" id="btn-view-pdf">👁 PDF-Vorschau</button>' : ''}
<button class="btn btn-secondary" id="btn-signature"> Kunden-Unterschrift hinzufügen</button>
<button class="btn btn-secondary" id="btn-open-editor"> Im Desktop-Editor öffnen</button>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="pa-close"></button>
<div class="fs-title">Seite bearbeiten</div>
<button class="icon-btn" id="pa-delete" title="Seite löschen">🗑</button>
</div>
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;">
${url ? `<img src="${url}" style="max-height:50vh;">` : '<div class="thumb-placeholder">⚠</div>'}
<label class="label" style="align-self:flex-start;">Notiz zur Seite (wird im PDF gedruckt):</label>
<textarea id="pa-note" rows="4" style="width:100%;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;padding:10px;font-size:14px;">${escapeHtml(note)}</textarea>
<button class="btn" id="pa-save-note">💾 Notiz speichern</button>
</div>
`;
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 = `
<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>
</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();
};
}
/* ============================================================
* UNTERSCHRIFT MODAL (Touch-Signatur)
* ============================================================ */
function openSignatureModal(berichtId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal signature-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="sig-close"></button>
<div class="fs-title"> Kunden-Unterschrift</div>
<button class="icon-btn" id="sig-save" title="Speichern"></button>
</div>
<div class="signature-toolbar">
<button id="sig-clear">🗑 Leeren</button>
<span class="sig-hint">Mit dem Finger unterschreiben</span>
</div>
<div class="signature-body">
<canvas id="sig-canvas"></canvas>
</div>
`;
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';

View file

@ -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 <img src> keine Authorization-Header mitschickt.
@ -155,5 +185,6 @@
getReport, listReports, finalizeReport,
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
getPhotoBlobUrl, clearPhotoCache,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl,
};
})();

View file

@ -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"] }
]
}
}
}

82
share.html Normal file
View file

@ -0,0 +1,82 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Foto teilen — Baustelle</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="app">
<header id="topbar">
<button id="back-btn" class="icon-btn" onclick="location.href='./'"></button>
<h1 id="page-title">📤 Foto teilen</h1>
<span></span>
</header>
<main id="main">
<div id="share-content">
<div class="loader">Foto wird empfangen…</div>
</div>
</main>
</div>
<script src="lib/idb.js"></script>
<script src="lib/api.js"></script>
<script>
/* Web Share Target Handler.
* Browser POST'et das geteilte Foto an diese URL.
* Wir zeigen eine Auftragsliste, User wählt einen Auftrag → Upload.
*/
(async function () {
const ct = document.getElementById('share-content');
const t = await api.getToken();
if (!t) {
ct.innerHTML = '<div class="empty-state"><div class="icon">🔐</div>Bitte zuerst anmelden<br><button class="btn" onclick="location.href=\'./\'">Zur App</button></div>';
return;
}
// Service Worker hat die geteilten Files unter 'shared_files' in IDB abgelegt
const sharedFiles = await idb.get('shared_files');
if (!sharedFiles || !sharedFiles.length) {
ct.innerHTML = '<div class="empty-state"><div class="icon">📭</div>Keine geteilten Fotos gefunden<br><button class="btn" onclick="location.href=\'./\'">Zur App</button></div>';
return;
}
try {
const data = await api.listOrders({ open: 1 });
const orders = data.orders || [];
ct.innerHTML = `
<p class="label" style="padding:0 16px;">${sharedFiles.length} Foto(s) geteilt. Wähle den Auftrag für den Upload:</p>
${orders.map(o => `
<div class="order-card" data-id="${o.id}">
<div class="ref">${escapeHtml(o.ref)}</div>
<div class="name">${escapeHtml(o.customer.name || '')}</div>
<div class="meta"><span>${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))}</span></div>
</div>
`).join('')}
`;
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', async () => {
const orderId = c.dataset.id;
ct.innerHTML = '<div class="loader">Lade hoch…</div>';
for (const f of sharedFiles) {
try {
// f.data ist ein File/Blob
await api.uploadOrderPhoto(orderId, f.data, f.name);
} catch (err) { console.warn('Upload failed', err); }
}
await idb.del('shared_files');
location.href = './#/orders/' + orderId;
});
});
} catch (e) {
ct.innerHTML = '<div class="empty-state"><div class="icon"></div>' + e.message + '</div>';
}
})();
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
</script>
</body>
</html>

31
sw.js
View file

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