feat: Phase 4 Block 1 — Seite bearbeiten, PDF-Vorschau, Unterschrift, Share Target
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
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:
parent
3f1b462105
commit
02ffdca57c
6 changed files with 377 additions and 3 deletions
52
app.css
52
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;
|
||||
|
|
|
|||
170
app.js
170
app.js
|
|
@ -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';
|
||||
|
|
|
|||
31
lib/api.js
31
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 <img src> keine Authorization-Header mitschickt.
|
||||
|
|
@ -155,5 +185,6 @@
|
|||
getReport, listReports, finalizeReport,
|
||||
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
||||
getPhotoBlobUrl, clearPhotoCache,
|
||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
82
share.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
31
sw.js
31
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue