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; }
|
.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-Cards für Kundendetail (Aufträge/Rechnungen/Berichte) */
|
||||||
.mini-card {
|
.mini-card {
|
||||||
background: #2a2a30;
|
background: #2a2a30;
|
||||||
|
|
@ -472,7 +529,12 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Report-Page-Thumb mit Nummer */
|
/* 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 {
|
.report-page-thumb .page-num {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px; left: 4px;
|
top: 4px; left: 4px;
|
||||||
|
|
|
||||||
268
app.js
268
app.js
|
|
@ -39,6 +39,107 @@ async function ensureAuth() {
|
||||||
return true;
|
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 ====== */
|
/* ====== ROUTES ====== */
|
||||||
|
|
||||||
router.on('/login', async () => {
|
router.on('/login', async () => {
|
||||||
|
|
@ -512,10 +613,8 @@ router.on('/reports/:id', async (args) => {
|
||||||
|
|
||||||
loadThumbs();
|
loadThumbs();
|
||||||
|
|
||||||
// Tap auf Report-Page-Thumb → Seiten-Aktionen-Modal
|
// Tap = Aktionen, Long-Press = Drag-Sort
|
||||||
document.querySelectorAll('.report-page-thumb').forEach(t => {
|
bindReportPageInteractions(args.id);
|
||||||
t.addEventListener('click', () => openPageActionsModal(args.id, t.dataset.pageId, t.dataset.relpath, t.dataset.note || ''));
|
|
||||||
});
|
|
||||||
|
|
||||||
// PDF-Vorschau
|
// PDF-Vorschau
|
||||||
const pdfBtn = document.getElementById('btn-view-pdf');
|
const pdfBtn = document.getElementById('btn-view-pdf');
|
||||||
|
|
@ -561,12 +660,25 @@ router.on('/settings', async () => {
|
||||||
setBack(false);
|
setBack(false);
|
||||||
|
|
||||||
const user = await idb.get('user') || {};
|
const user = await idb.get('user') || {};
|
||||||
|
const pinEnabled = !!(await idb.get('pin_hash'));
|
||||||
|
|
||||||
main().innerHTML = `
|
main().innerHTML = `
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>Konto</h3>
|
<h3>Konto</h3>
|
||||||
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
|
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
|
||||||
<p class="label">${escapeHtml(user.login || '')}</p>
|
<p class="label">${escapeHtml(user.login || '')}</p>
|
||||||
</div>
|
</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-sync">🔄 Offline-Queue synchronisieren</button>
|
||||||
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</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>
|
<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 () => {
|
document.getElementById('btn-logout').onclick = async () => {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
|
await idb.del('pin_hash');
|
||||||
|
await idb.del('pin_salt');
|
||||||
router.go('#/login');
|
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 */
|
/* Bottom nav + Hilfe-Button */
|
||||||
|
|
@ -590,6 +737,83 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (helpBtn) helpBtn.addEventListener('click', openHelpModal);
|
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)
|
* 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>
|
<button class="icon-btn" id="sk-save" title="Speichern">✓</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sketch-toolbar">
|
<div class="sketch-toolbar">
|
||||||
<button class="sk-tool active" data-tool="pen">✏️</button>
|
<button class="sk-tool active" data-tool="pen" title="Stift">✏️</button>
|
||||||
<button class="sk-tool" data-tool="arrow">↗</button>
|
<button class="sk-tool" data-tool="arrow" title="Pfeil">↗</button>
|
||||||
<button class="sk-tool" data-tool="rect">▭</button>
|
<button class="sk-tool" data-tool="rect" title="Rechteck">▭</button>
|
||||||
<button class="sk-tool" data-tool="circle">○</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>
|
<span class="sep"></span>
|
||||||
<input type="color" id="sk-color" value="#ff0000">
|
<input type="color" id="sk-color" value="#ff0000">
|
||||||
<input type="range" id="sk-width" min="2" max="20" value="5">
|
<input type="range" id="sk-width" min="2" max="20" value="5">
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button id="sk-undo">↶</button>
|
<button id="sk-undo" title="Rückgängig">↶</button>
|
||||||
<button id="sk-clear">🗑</button>
|
<button id="sk-clear" title="Alles löschen">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sketch-body">
|
<div class="sketch-body">
|
||||||
<canvas id="sk-canvas"></canvas>
|
<canvas id="sk-canvas"></canvas>
|
||||||
|
|
@ -1067,6 +1296,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let tool = 'pen';
|
let tool = 'pen';
|
||||||
|
let stampChar = null;
|
||||||
let color = '#ff0000';
|
let color = '#ff0000';
|
||||||
let lineWidth = 5;
|
let lineWidth = 5;
|
||||||
let drawing = false;
|
let drawing = false;
|
||||||
|
|
@ -1090,6 +1320,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active'));
|
modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active'));
|
||||||
b.classList.add('active');
|
b.classList.add('active');
|
||||||
tool = b.dataset.tool;
|
tool = b.dataset.tool;
|
||||||
|
stampChar = b.dataset.stamp || null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
modal.querySelector('#sk-color').oninput = e => { color = e.target.value; };
|
modal.querySelector('#sk-color').oninput = e => { color = e.target.value; };
|
||||||
|
|
@ -1118,9 +1349,24 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
|
|
||||||
function startDraw(e) {
|
function startDraw(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
drawing = true;
|
|
||||||
const p = getPos(e);
|
const p = getPos(e);
|
||||||
startX = p.x; startY = p.y;
|
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.strokeStyle = color;
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.lineWidth = lineWidth;
|
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) {
|
async function getPdfBlobUrl(berichtId) {
|
||||||
const t = await getToken();
|
const t = await getToken();
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
|
|
@ -200,6 +207,6 @@
|
||||||
getReport, listReports, finalizeReport,
|
getReport, listReports, finalizeReport,
|
||||||
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
||||||
getPhotoBlobUrl, clearPhotoCache,
|
getPhotoBlobUrl, clearPhotoCache,
|
||||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl,
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('hashchange', () => navigate());
|
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 };
|
window.router = { on, navigate, go };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue