PWA: Dokumente (PDFs) in Mehrfachauswahl + Back-Button schließt Auswahl [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 4s

- PDFs/Dokumente lassen sich jetzt auch im Auswahl-Modus markieren und
  mitteilen (bisher öffnete der Tap immer den Viewer)
- ☑ Auswählen-Button erscheint auch wenn nur Dokumente vorhanden sind
- Android-Zurück-Button im Select-Modus: hebt die Auswahl auf und
  beendet den Auswahl-Modus (statt die Seite zu verlassen)
- ✕ Abbrechen-Button nutzt denselben History-Back-Pfad

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-18 12:38:06 +02:00
parent 427fdeb0c0
commit 0a78d9e5d9
2 changed files with 73 additions and 14 deletions

24
app.css
View file

@ -352,6 +352,30 @@ body {
border-color: #fff;
}
/* Auswahl-Mode in der Dokumenten-Liste (PDFs etc.) */
.doc-item .doc-check {
display: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: transparent;
border: 2px solid #888;
color: transparent;
font-size: 14px;
font-weight: bold;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.doc-list.selecting .doc-item .doc-check { display: flex; }
.doc-list.selecting .doc-item .doc-open { pointer-events: none; opacity: 0.4; }
.doc-list.selecting .doc-item.selected { background: rgba(90,176,255,0.15); outline: 2px solid #5ab0ff; }
.doc-list.selecting .doc-item.selected .doc-check {
background: #5ab0ff;
border-color: #5ab0ff;
color: #fff;
}
/* Sticky Aktions-Bar am unteren Bildschirmrand */
.select-bar {
position: fixed;

63
app.js
View file

@ -45,6 +45,9 @@ function setBack(visible, hash) {
const modalStack = [];
let _skipNextPopstate = false;
let _lastBackAt = 0;
// Aktiver Select-Mode-Cleanup: wird von popstate gerufen, damit Android-Back
// eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen.
let selectModeCleanup = null;
function pushModal(el, cleanup) {
modalStack.push({ el, cleanup: cleanup || null });
@ -83,6 +86,12 @@ window.addEventListener('popstate', () => {
try { top.cleanup && top.cleanup(); } catch {}
return;
}
if (selectModeCleanup) {
const fn = selectModeCleanup;
selectModeCleanup = null;
try { fn(); } catch {}
return;
}
if (isTopLevelHash(location.hash)) {
const now = Date.now();
if (now - _lastBackAt < 2000) return;
@ -535,7 +544,7 @@ router.on('/orders/:id', async (args) => {
<div class="detail-section" style="margin-top:16px;">
<div class="photo-section-head">
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
${imagePhotos.length ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
</div>
<div class="photo-grid" id="photo-grid">
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div><div class="thumb-check">✓</div></div>`).join('')}
@ -558,6 +567,7 @@ router.on('/orders/:id', async (args) => {
<h3>Weitere Dokumente (${otherDocs.length})</h3>
<div class="doc-list">
${otherDocs.map(p => `<div class="doc-item" data-relpath="${escapeHtml(p.relpath)}" data-mime="${escapeHtml(p.mime || '')}" data-filename="${escapeHtml(p.filename)}">
<div class="doc-check"></div>
<span class="doc-icon">${docIconFor(p.filename, p.mime)}</span>
<div class="doc-meta">
<div class="doc-name">${escapeHtml(p.filename)}</div>
@ -571,6 +581,7 @@ router.on('/orders/:id', async (args) => {
// Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus)
const photoGrid = document.getElementById('photo-grid');
const docList = document.querySelector('.doc-list');
photoGrid.querySelectorAll('.thumb').forEach(t => {
t.addEventListener('click', () => {
if (photoGrid.classList.contains('selecting')) {
@ -586,17 +597,33 @@ router.on('/orders/:id', async (args) => {
const selectBtn = document.getElementById('btn-select-mode');
if (selectBtn) selectBtn.onclick = () => startSelectMode();
function allSelectables() {
const thumbs = Array.from(photoGrid.querySelectorAll('.thumb[data-relpath]'));
const docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : [];
return thumbs.concat(docs);
}
function startSelectMode() {
if (photoGrid.classList.contains('selecting')) return;
photoGrid.classList.add('selecting');
if (docList) docList.classList.add('selecting');
renderSelectBar();
updateSelectBar();
// Android-Back soll nur die Auswahl aufheben
history.pushState({ _selecting: true }, '', location.hash);
selectModeCleanup = endSelectMode;
}
function endSelectMode() {
selectModeCleanup = null;
photoGrid.classList.remove('selecting');
photoGrid.querySelectorAll('.thumb.selected').forEach(x => x.classList.remove('selected'));
if (docList) docList.classList.remove('selecting');
allSelectables().forEach(x => x.classList.remove('selected'));
const bar = document.getElementById('select-bar');
if (bar) bar.remove();
}
function cancelViaBack() {
if (history.state && history.state._selecting) history.back();
else endSelectMode();
}
function renderSelectBar() {
if (document.getElementById('select-bar')) return;
const bar = document.createElement('div');
@ -609,42 +636,50 @@ router.on('/orders/:id', async (args) => {
<button class="icon-btn" id="sb-share" title="Teilen" disabled>📤</button>
`;
document.body.appendChild(bar);
bar.querySelector('#sb-cancel').onclick = endSelectMode;
bar.querySelector('#sb-cancel').onclick = cancelViaBack;
bar.querySelector('#sb-all').onclick = () => {
const thumbs = photoGrid.querySelectorAll('.thumb[data-relpath]');
const anyUn = Array.from(thumbs).some(t => !t.classList.contains('selected'));
thumbs.forEach(t => t.classList.toggle('selected', anyUn));
const items = allSelectables();
const anyUn = items.some(t => !t.classList.contains('selected'));
items.forEach(t => t.classList.toggle('selected', anyUn));
updateSelectBar();
};
bar.querySelector('#sb-share').onclick = () => shareSelected();
}
function updateSelectBar() {
const n = photoGrid.querySelectorAll('.thumb.selected').length;
const n = allSelectables().filter(x => x.classList.contains('selected')).length;
const info = document.getElementById('sb-info');
const share = document.getElementById('sb-share');
if (info) info.textContent = n + ' ausgewählt';
if (share) share.disabled = n === 0;
}
async function shareSelected() {
const sel = Array.from(photoGrid.querySelectorAll('.thumb.selected'));
const sel = allSelectables().filter(x => x.classList.contains('selected'));
if (!sel.length) return;
showToast('Lade ' + sel.length + ' Foto' + (sel.length === 1 ? '' : 's') + '…');
showToast('Lade ' + sel.length + ' Datei' + (sel.length === 1 ? '' : 'en') + '…');
const items = [];
for (const t of sel) {
const rp = t.dataset.relpath;
try {
const f = await api.getFileBlobUrl(rp);
if (f) items.push({ blob: f.blob, filename: rp.split('/').pop(), mime: f.mime });
if (f) items.push({ blob: f.blob, filename: t.dataset.filename || rp.split('/').pop(), mime: f.mime });
} catch (e) { console.warn('Share-Load', rp, e); }
}
if (!items.length) { showToast('Keine Dateien geladen', 'error'); return; }
const ok = await shareFiles(items, 'Fotos vom Auftrag');
if (ok) endSelectMode();
const ok = await shareFiles(items, 'Dateien vom Auftrag');
if (ok) cancelViaBack();
}
// Weitere Dokumente: Tap = Öffnen (PDF inline, sonst Download)
// Weitere Dokumente: Tap = Öffnen (oder Auswahl im Select-Modus)
document.querySelectorAll('.doc-list .doc-item').forEach(el => {
el.addEventListener('click', async () => {
el.addEventListener('click', async (e) => {
if (docList && docList.classList.contains('selecting')) {
// Auch Klick auf Öffnen-Button soll nur selektieren
e.preventDefault();
e.stopPropagation();
el.classList.toggle('selected');
updateSelectBar();
return;
}
const rel = el.dataset.relpath;
const filename = el.dataset.filename;
el.classList.add('loading');