feat: PIN-Schutz + Seiten-Reorder + Stamps
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
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) <noreply@anthropic.com>
[deploy]
This commit is contained in:
parent
503d36bd09
commit
0e8ebed717
4 changed files with 334 additions and 14 deletions
64
app.css
64
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;
|
||||
|
|
|
|||
268
app.js
268
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 = `
|
||||
<div class="pin-body">
|
||||
<div class="pin-icon">🔐</div>
|
||||
<div class="pin-title">${escapeHtml(title || 'PIN eingeben')}</div>
|
||||
<div class="pin-display"><span></span><span></span><span></span><span></span></div>
|
||||
<div class="pin-keypad"></div>
|
||||
<div class="pin-error" id="pin-error"></div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="detail-section">
|
||||
<h3>Konto</h3>
|
||||
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
|
||||
<p class="label">${escapeHtml(user.login || '')}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Sicherheit</h3>
|
||||
<p class="label">PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.</p>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="pin-toggle" ${pinEnabled ? 'checked' : ''}>
|
||||
<span>PIN beim Öffnen abfragen</span>
|
||||
</label>
|
||||
${pinEnabled ? '<button class="btn btn-secondary" id="btn-pin-change">🔑 PIN ändern</button>' : ''}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button>
|
||||
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</button>
|
||||
<p class="label" style="text-align:center;margin-top:24px;">Baustelle PWA v1.0</p>
|
||||
|
|
@ -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) {
|
|||
<button class="icon-btn" id="sk-save" title="Speichern">✓</button>
|
||||
</div>
|
||||
<div class="sketch-toolbar">
|
||||
<button class="sk-tool active" data-tool="pen">✏️</button>
|
||||
<button class="sk-tool" data-tool="arrow">↗</button>
|
||||
<button class="sk-tool" data-tool="rect">▭</button>
|
||||
<button class="sk-tool" data-tool="circle">○</button>
|
||||
<button class="sk-tool active" data-tool="pen" title="Stift">✏️</button>
|
||||
<button class="sk-tool" data-tool="arrow" title="Pfeil">↗</button>
|
||||
<button class="sk-tool" data-tool="rect" title="Rechteck">▭</button>
|
||||
<button class="sk-tool" data-tool="circle" title="Kreis">○</button>
|
||||
<span class="sep"></span>
|
||||
<button class="sk-tool" data-tool="stamp" data-stamp="⚠" title="Achtung">⚠</button>
|
||||
<button class="sk-tool" data-tool="stamp" data-stamp="✓" title="OK">✓</button>
|
||||
<button class="sk-tool" data-tool="stamp" data-stamp="✗" title="Mangel">✗</button>
|
||||
<button class="sk-tool" data-tool="stamp" data-stamp="🔧" title="Reparatur">🔧</button>
|
||||
<span class="sep"></span>
|
||||
<input type="color" id="sk-color" value="#ff0000">
|
||||
<input type="range" id="sk-width" min="2" max="20" value="5">
|
||||
<span class="sep"></span>
|
||||
<button id="sk-undo">↶</button>
|
||||
<button id="sk-clear">🗑</button>
|
||||
<button id="sk-undo" title="Rückgängig">↶</button>
|
||||
<button id="sk-clear" title="Alles löschen">🗑</button>
|
||||
</div>
|
||||
<div class="sketch-body">
|
||||
<canvas id="sk-canvas"></canvas>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue