baustelle-pwa/app.js
Eduard Wisch a234de58c5
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
fix: Thumbs via api/photo.php mit Blob-URLs laden (JWT-kompatibel)
<img src> kann keine Authorization-Header schicken. Wir holen die
Bilder jetzt via fetch() mit Bearer-Token und setzen Blob-URLs in die
Thumbnails ein (mit Cache für wiederholte Abrufe). Vorher zeigte die
PWA leere Bild-Placeholder weil document.php eine Session verlangt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
2026-04-08 23:18:09 +02:00

285 lines
11 KiB
JavaScript

/* Baustelle PWA Hauptlogik. Alle Routen + Views in einer Datei. */
const main = () => document.getElementById('main');
const title = (s) => document.getElementById('page-title').textContent = s;
window.showToast = function (msg, kind) {
const t = document.createElement('div');
t.className = 'toast' + (kind ? ' ' + kind : '');
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 2800);
};
function showLoader(text) {
main().innerHTML = '<div class="loader">' + (text || 'Lade…') + '</div>';
}
function setNav(visible, active) {
const nav = document.getElementById('bottom-nav');
nav.style.display = visible ? '' : 'none';
nav.querySelectorAll('button').forEach(b => {
b.classList.toggle('active', b.dataset.route === active);
});
}
function setBack(visible, hash) {
const btn = document.getElementById('back-btn');
btn.style.display = visible ? '' : 'none';
btn.onclick = () => { if (hash) router.go(hash); else history.back(); };
}
/* ----- Auth-Check ----- */
async function ensureAuth() {
const t = await api.getToken();
if (!t) {
router.go('#/login');
return false;
}
return true;
}
/* ====== ROUTES ====== */
router.on('/login', async () => {
title('Anmelden');
setNav(false);
setBack(false);
main().innerHTML = `
<form class="login-form" id="login-form">
<h2>🔧 Baustelle</h2>
<input type="text" name="login" placeholder="Benutzername" autocomplete="username" required>
<input type="password" name="password" placeholder="Passwort" autocomplete="current-password" required>
<button type="submit" class="btn btn-large">Anmelden</button>
</form>
`;
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.login(fd.get('login'), fd.get('password'));
showToast('Erfolgreich angemeldet');
router.go('#/orders');
} catch (err) {
showToast(err.message, 'error');
}
});
});
router.on('/orders', async () => {
if (!(await ensureAuth())) return;
title('Aufträge');
setNav(true, 'orders');
setBack(false);
showLoader('Lade Aufträge…');
try {
const data = await api.listOrders({ open: 1 });
if (!data.orders.length) {
main().innerHTML = '<div class="empty-state"><div class="icon">📭</div>Keine offenen Aufträge</div>';
return;
}
const html = `
<div class="search-bar"><input type="search" id="order-search" placeholder="🔍 Suchen…"></div>
<div id="order-list">${renderOrderList(data.orders)}</div>
`;
main().innerHTML = html;
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
});
document.getElementById('order-search').addEventListener('input', async (e) => {
const q = e.target.value;
const d = await api.listOrders({ q, open: 1 });
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
});
});
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
function renderOrderList(orders) {
return 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>
${o.bericht_count > 0 ? `<span class="badge">📑 ${o.bericht_count}</span>` : ''}
</div>
</div>
`).join('');
}
router.on('/orders/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'orders');
setBack(true, '#/orders');
showLoader('Lade Auftrag…');
try {
const data = await api.getOrder(args.id);
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
title(data.order.ref);
main().innerHTML = `
<div class="detail-section">
<h3>Kunde</h3>
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
<p>${escapeHtml(data.customer.address || '')}</p>
<p>${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}</p>
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
</div>
${data.order.auftragsbeschreibung ? `
<div class="detail-section">
<h3>Beschreibung</h3>
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
</div>` : ''}
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
<div class="detail-section" style="margin-top:16px;">
<h3>Hochgeladene Fotos (${photos.photos.length})</h3>
<div class="photo-grid" id="photo-grid">
${photos.photos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('')}
</div>
</div>
`;
loadThumbs();
const camInput = document.getElementById('camera-input');
const galInput = document.getElementById('gallery-input');
document.getElementById('btn-take-photo').onclick = () => camInput.click();
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
async function handleFiles(files) {
for (const f of files) {
await uploadPhoto(args.id, f);
}
// Reload Photo-Liste
try {
const np = await api.listOrderPhotos(args.id);
document.getElementById('photo-grid').innerHTML =
np.photos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('');
loadThumbs();
} catch (e) {}
}
camInput.addEventListener('change', () => handleFiles(camInput.files));
galInput.addEventListener('change', () => handleFiles(galInput.files));
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
async function uploadPhoto(orderId, file) {
showToast('Optimiere & sende ' + file.name);
const blob = await resizeImage(file, 2000);
if (!navigator.onLine) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Offline — Foto in Queue', 'warn');
return;
}
try {
await api.uploadOrderPhoto(orderId, blob, file.name);
showToast('✓ ' + file.name + ' hochgeladen');
} catch (e) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Upload fehlgeschlagen — in Queue', 'error');
}
}
async function resizeImage(file, maxSide) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
if (scale === 1) { resolve(file); return; }
const c = document.createElement('canvas');
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.toBlob(b => resolve(b || file), 'image/jpeg', 0.85);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
}
/**
* Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid.
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
* deshalb Blob-URLs).
*/
async function loadThumbs() {
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
for (const t of thumbs) {
const rel = t.dataset.relpath;
try {
// Erst Thumbnail versuchen (_small), bei Misserfolg das Original
let url = await api.getPhotoBlobUrl(rel, 'small');
if (!url) url = await api.getPhotoBlobUrl(rel);
if (url) {
t.innerHTML = '<img loading="lazy" src="' + url + '">';
} else {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
} catch (e) {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
}
}
router.on('/reports', async () => {
if (!(await ensureAuth())) return;
title('Berichte');
setNav(true, 'reports');
setBack(false);
main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Berichte-Liste folgt</div>';
});
router.on('/settings', async () => {
if (!(await ensureAuth())) return;
title('Einstellungen');
setNav(true, 'settings');
setBack(false);
const user = await idb.get('user') || {};
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>
<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>
`;
document.getElementById('btn-sync').onclick = async () => {
await offline.syncQueue();
showToast('Sync ausgelöst');
};
document.getElementById('btn-logout').onclick = async () => {
await api.logout();
router.go('#/login');
};
});
/* Bottom nav */
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#bottom-nav button').forEach(b => {
b.addEventListener('click', () => router.go('#/' + b.dataset.route));
});
});
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}