From 0e8ebed7170c0acfa631f4b51c20611caed195b7 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Thu, 9 Apr 2026 08:18:53 +0200 Subject: [PATCH] feat: PIN-Schutz + Seiten-Reorder + Stamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4.c PIN-Schutz (optional): - Settings → Sicherheit → Toggle aktiviert PIN - 4-stelliger Keypad-Dialog (promptPin/promptNewPin) - SHA256 mit random Salt, beides in IndexedDB - appBoot() Lockscreen vor Router-Start - PIN ändern, deaktivieren jederzeit möglich - Logout löscht PIN-Daten 4.f Seiten umsortieren: - Long-Press (500ms) auf Seiten-Thumb → Drag-Modus - Touch-basierter Reorder mit elementFromPoint - Vibrationshinweis beim Drag-Start - Beim Release: api.reorderPages + Nummern-Badges updaten 5.1 Stamps im Sketch-Editor: - Vier vordefinierte Stempel in der Toolbar: ⚠ Achtung, ✓ OK, ✗ Mangel, 🔧 Reparatur - Ein-Klick-Platzierung, Farbe aus Color-Picker, weißer Outline damit sie auf jedem Bild sichtbar sind - 96px Bold-Font Co-Authored-By: Claude Opus 4.6 (1M context) [deploy] --- app.css | 64 +++++++++++- app.js | 268 +++++++++++++++++++++++++++++++++++++++++++++++--- lib/api.js | 9 +- lib/router.js | 7 +- 4 files changed, 334 insertions(+), 14 deletions(-) diff --git a/app.css b/app.css index 2579ad6..97a8196 100644 --- a/app.css +++ b/app.css @@ -439,6 +439,63 @@ body { .btn[disabled] { opacity: 0.5; cursor: not-allowed; } +/* PIN-Modal */ +.pin-modal .pin-body { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + background: #1a1a1f; +} +.pin-modal .pin-icon { font-size: 56px; margin-bottom: 12px; } +.pin-modal .pin-title { font-size: 18px; color: #e0e0e0; margin-bottom: 24px; } +.pin-modal .pin-display { + display: flex; gap: 16px; margin-bottom: 32px; +} +.pin-modal .pin-display span { + width: 18px; height: 18px; border-radius: 50%; + border: 2px solid #7aa2f7; background: transparent; + transition: background 0.1s; +} +.pin-modal .pin-display span.filled { background: #7aa2f7; } +.pin-modal .pin-keypad { + display: grid; + grid-template-columns: repeat(3, 72px); + gap: 12px; +} +.pin-modal .pin-key { + width: 72px; height: 72px; + border-radius: 50%; + background: #2a2a30; + color: #fff; + border: 1px solid #444; + font-size: 24px; + cursor: pointer; + -webkit-appearance: none; +} +.pin-modal .pin-key:active { background: #3a3a40; transform: scale(0.97); } +.pin-modal .pin-cancel { + margin-top: 32px; + background: transparent; + border: none; + color: #999; + cursor: pointer; + font-size: 14px; +} + +/* Toggle-Row für Settings */ +.toggle-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 0; + cursor: pointer; +} +.toggle-row input[type="checkbox"] { + width: 18px; height: 18px; + accent-color: #337ab7; +} + /* Mini-Cards für Kundendetail (Aufträge/Rechnungen/Berichte) */ .mini-card { background: #2a2a30; @@ -472,7 +529,12 @@ body { } /* Report-Page-Thumb mit Nummer */ -.report-page-thumb { position: relative; } +.report-page-thumb { position: relative; transition: transform 0.1s, opacity 0.1s; } +.report-page-thumb.dragging { + opacity: 0.6; + transform: scale(1.05); + box-shadow: 0 4px 20px rgba(51, 122, 183, 0.5); +} .report-page-thumb .page-num { position: absolute; top: 4px; left: 4px; diff --git a/app.js b/app.js index 6fb676c..ffdebfc 100644 --- a/app.js +++ b/app.js @@ -39,6 +39,107 @@ async function ensureAuth() { return true; } +/* ============================================================ + * PIN-Schutz (optional, Settings → Sicherheit) + * ============================================================ */ +async function hashPin(pin) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const saltStr = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join(''); + const enc = new TextEncoder(); + const data = enc.encode(saltStr + ':' + pin); + const buf = await crypto.subtle.digest('SHA-256', data); + const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + return { hash, salt: saltStr }; +} +async function verifyPin(pin) { + const salt = await idb.get('pin_salt'); + const stored = await idb.get('pin_hash'); + if (!salt || !stored) return false; + const enc = new TextEncoder(); + const data = enc.encode(salt + ':' + pin); + const buf = await crypto.subtle.digest('SHA-256', data); + const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + return hash === stored; +} +function promptPin(title) { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal pin-modal'; + modal.innerHTML = ` +
+
🔐
+
${escapeHtml(title || 'PIN eingeben')}
+
+
+
+
+ `; + const pad = modal.querySelector('.pin-keypad'); + const disp = modal.querySelectorAll('.pin-display span'); + let current = ''; + function render() { + disp.forEach((el, i) => { el.classList.toggle('filled', i < current.length); }); + } + function add(d) { + if (current.length >= 4) return; + current += d; + render(); + if (current.length === 4) { + setTimeout(() => { modal.remove(); resolve(current); }, 150); + } + } + function del() { + current = current.slice(0, -1); + render(); + } + const keys = ['1','2','3','4','5','6','7','8','9','','0','←']; + keys.forEach(k => { + const b = document.createElement('button'); + b.className = 'pin-key'; + b.textContent = k; + if (!k) { b.style.visibility = 'hidden'; } + else if (k === '←') b.onclick = del; + else b.onclick = () => add(k); + pad.appendChild(b); + }); + // Cancel-Button + const cancel = document.createElement('button'); + cancel.className = 'pin-cancel'; + cancel.textContent = 'Abbrechen'; + cancel.onclick = () => { modal.remove(); resolve(null); }; + modal.querySelector('.pin-body').appendChild(cancel); + document.body.appendChild(modal); + }); +} +async function promptNewPin() { + const p1 = await promptPin('Neue 4-stellige PIN'); + if (!p1 || p1.length !== 4) return null; + const p2 = await promptPin('PIN wiederholen'); + if (p2 !== p1) { + alert('PINs stimmen nicht überein'); + return null; + } + return p1; +} + +/** + * Startet die App — fragt ggf. PIN ab bevor router läuft. + */ +window.appBoot = async function appBoot() { + const pinSet = await idb.get('pin_hash'); + if (!pinSet) return; // kein PIN-Schutz aktiv + // Lockscreen: vollständiger Overlay bis richtige PIN + document.getElementById('app').style.visibility = 'hidden'; + while (true) { + const pin = await promptPin('PIN eingeben'); + if (pin && (await verifyPin(pin))) break; + // Falsch → kurzes Feedback und nochmal + showToast('Falsche PIN', 'error'); + } + document.getElementById('app').style.visibility = ''; +} + + /* ====== ROUTES ====== */ router.on('/login', async () => { @@ -512,10 +613,8 @@ router.on('/reports/:id', async (args) => { 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 || '')); - }); + // Tap = Aktionen, Long-Press = Drag-Sort + bindReportPageInteractions(args.id); // PDF-Vorschau const pdfBtn = document.getElementById('btn-view-pdf'); @@ -561,12 +660,25 @@ router.on('/settings', async () => { setBack(false); const user = await idb.get('user') || {}; + const pinEnabled = !!(await idb.get('pin_hash')); + main().innerHTML = `

Konto

${escapeHtml(user.name || user.login || 'Unbekannt')}

${escapeHtml(user.login || '')}

+ +
+

Sicherheit

+

PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.

+ + ${pinEnabled ? '' : ''} +
+

Baustelle PWA v1.0

@@ -577,8 +689,43 @@ router.on('/settings', async () => { }; document.getElementById('btn-logout').onclick = async () => { await api.logout(); + await idb.del('pin_hash'); + await idb.del('pin_salt'); router.go('#/login'); }; + document.getElementById('pin-toggle').addEventListener('change', async (e) => { + if (e.target.checked) { + // PIN setzen + const pin = await promptNewPin(); + if (!pin) { e.target.checked = false; return; } + const { hash, salt } = await hashPin(pin); + await idb.set('pin_salt', salt); + await idb.set('pin_hash', hash); + showToast('✓ PIN gesetzt'); + router.navigate(); + } else { + if (!confirm('PIN-Schutz wirklich deaktivieren?')) { + e.target.checked = true; + return; + } + await idb.del('pin_hash'); + await idb.del('pin_salt'); + showToast('PIN-Schutz aus'); + router.navigate(); + } + }); + const changeBtn = document.getElementById('btn-pin-change'); + if (changeBtn) changeBtn.onclick = async () => { + const old = await promptPin('Aktuelle PIN eingeben'); + if (!old) return; + if (!(await verifyPin(old))) { showToast('Falsche PIN', 'error'); return; } + const neu = await promptNewPin(); + if (!neu) return; + const { hash, salt } = await hashPin(neu); + await idb.set('pin_salt', salt); + await idb.set('pin_hash', hash); + showToast('✓ PIN geändert'); + }; }); /* Bottom nav + Hilfe-Button */ @@ -590,6 +737,83 @@ document.addEventListener('DOMContentLoaded', () => { if (helpBtn) helpBtn.addEventListener('click', openHelpModal); }); +/** + * Bindet Tap + Long-Press-Drag an alle .report-page-thumb des Bericht-Details. + */ +function bindReportPageInteractions(reportId) { + const grid = document.getElementById('report-pages'); + if (!grid) return; + const thumbs = Array.from(grid.querySelectorAll('.report-page-thumb')); + + let longPressTimer = null; + let dragging = null; + let touchStart = null; + + thumbs.forEach(t => { + // Click/Tap → Aktion-Modal (nur wenn nicht gedraggt wurde) + t.addEventListener('click', (e) => { + if (dragging) { e.preventDefault(); return; } + openPageActionsModal(reportId, t.dataset.pageId, t.dataset.relpath, t.dataset.note || ''); + }); + + // Long-Press → Drag-Modus + const startLongPress = (e) => { + touchStart = e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null; + longPressTimer = setTimeout(() => { + dragging = t; + t.classList.add('dragging'); + showToast('Seite verschieben — ziehen und loslassen'); + if (navigator.vibrate) navigator.vibrate(50); + }, 500); + }; + const cancelLongPress = () => { + if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } + }; + + t.addEventListener('touchstart', startLongPress, { passive: true }); + t.addEventListener('touchmove', (e) => { + if (!dragging && touchStart) { + const dx = e.touches[0].clientX - touchStart.x; + const dy = e.touches[0].clientY - touchStart.y; + if (Math.abs(dx) > 8 || Math.abs(dy) > 8) cancelLongPress(); + } + if (dragging) { + e.preventDefault(); + // Welcher Thumb liegt unter dem Finger? + const tx = e.touches[0].clientX; + const ty = e.touches[0].clientY; + const below = document.elementFromPoint(tx, ty); + const target = below ? below.closest('.report-page-thumb') : null; + if (target && target !== dragging) { + const rect = target.getBoundingClientRect(); + const middle = rect.left + rect.width / 2; + if (tx < middle) grid.insertBefore(dragging, target); + else grid.insertBefore(dragging, target.nextSibling); + } + } + }, { passive: false }); + t.addEventListener('touchend', async () => { + cancelLongPress(); + if (dragging) { + dragging.classList.remove('dragging'); + const ids = Array.from(grid.querySelectorAll('.report-page-thumb')).map(x => x.dataset.pageId); + try { + await api.reorderPages(ids); + showToast('✓ Reihenfolge gespeichert'); + // Page-Nummer-Badges neu setzen + Array.from(grid.querySelectorAll('.report-page-thumb')).forEach((el, i) => { + const num = el.querySelector('.page-num'); + if (num) num.textContent = (i + 1); + }); + } catch (e) { showToast('Fehler: ' + e.message, 'error'); } + dragging = null; + } + touchStart = null; + }); + t.addEventListener('touchcancel', () => { cancelLongPress(); dragging = null; touchStart = null; }); + }); +} + /* ============================================================ * SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild) * ============================================================ */ @@ -1021,16 +1245,21 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
- - - - + + + + + + + + + - - + +
@@ -1067,6 +1296,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { // State let tool = 'pen'; + let stampChar = null; let color = '#ff0000'; let lineWidth = 5; let drawing = false; @@ -1090,6 +1320,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active')); b.classList.add('active'); tool = b.dataset.tool; + stampChar = b.dataset.stamp || null; }); }); modal.querySelector('#sk-color').oninput = e => { color = e.target.value; }; @@ -1118,9 +1349,24 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { function startDraw(e) { e.preventDefault(); - drawing = true; const p = getPos(e); startX = p.x; startY = p.y; + + // Stamps sind ein Single-Click-Tool (kein Drag) + if (tool === 'stamp' && stampChar) { + ctx.fillStyle = color; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 4; + ctx.font = 'bold 96px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.strokeText(stampChar, p.x, p.y); + ctx.fillText(stampChar, p.x, p.y); + pushHistory(); + return; + } + + drawing = true; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lineWidth; diff --git a/lib/api.js b/lib/api.js index a746a5a..795c97d 100644 --- a/lib/api.js +++ b/lib/api.js @@ -144,6 +144,13 @@ }); } + async function reorderPages(pageIds) { + return request('/pages.php?action=reorder', { + method: 'POST', + body: JSON.stringify({ order: pageIds }), + }); + } + async function getPdfBlobUrl(berichtId) { const t = await getToken(); if (!t) return null; @@ -200,6 +207,6 @@ getReport, listReports, finalizeReport, deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto, getPhotoBlobUrl, clearPhotoCache, - deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, + deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages, }; })(); diff --git a/lib/router.js b/lib/router.js index 64e16c2..3008bdd 100644 --- a/lib/router.js +++ b/lib/router.js @@ -49,7 +49,12 @@ } window.addEventListener('hashchange', () => navigate()); - document.addEventListener('DOMContentLoaded', () => navigate()); + document.addEventListener('DOMContentLoaded', async () => { + if (typeof window.appBoot === 'function') { + try { await window.appBoot(); } catch (e) {} + } + navigate(); + }); window.router = { on, navigate, go }; })();