Teilen aus WhatsApp: Bilder UND Text landen jetzt wirklich im Auftrag [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s

Jeder Teilen-Vorgang endete bisher in einer Sackgasse — Login-Hinweis oder
"Keine geteilten Fotos gefunden", und danach war das Geteilte weg. Zwei Ursachen:

1. share.html prüfte api.getToken() — ein JWT in IndexedDB, das es seit der
   SSO-Umstellung nicht mehr gibt (api.login() legt nur noch 'user' ab, die
   Sitzung steckt im HttpOnly-Cookie awl_sso). Der Wert war immer leer, also kam
   IMMER "Bitte zuerst anmelden"; der Weg "Zur App" verwarf das Geteilte.
   Jetzt: Cookie-Prüfung über api/verify.php wie in ensureAuth(), und die
   Anmeldung (Passwort + Passkey) läuft IN der Teilen-Seite — das Geteilte bleibt
   dabei gesichert und der Ablauf geht danach direkt weiter.

2. Der Service Worker sicherte nur Dateien. Titel, Text und Link aus dem
   share_target wurden weggeworfen, ein reiner Text-Share schrieb gar nichts.
   Jetzt wird das ganze Paket gesichert und immer weitergeleitet (absolute
   Redirect-URL, leere File-Platzhalter fliegen raus).

Die Teilen-Seite zeigt Bildvorschau, Betreff und Text zum Nachbessern und eine
Auftragssuche (ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle).
Beim Ablegen gehen die Bilder über die normale Upload-Queue (Persist-First),
der Text wird als notiz_*.txt über api/note.php gespeichert. Scheitert die
Notiz (kein Netz), bleibt der Text gesichert und der Vorgang ist wiederholbar.

Dazu ein Banner in der App, wenn noch ein Paket unabgelegt bereitliegt —
abgebrochene Vorgänge findet sonst niemand wieder. share.html liest auch den
alten IndexedDB-Schlüssel, damit der erste Share nach dem Deploy (noch alter
Service Worker) nicht verlorengeht.

Lokal durchgespielt: Bild+Text, nur Text, und abgemeldet teilen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-08-30 18:03:46 +02:00
parent c2bf4e7c30
commit 707638808c
6 changed files with 451 additions and 87 deletions

View file

@ -66,7 +66,9 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
**PWA** **PWA**
- ✅ Service Worker für Offline-Start - ✅ Service Worker für Offline-Start
- ✅ Installierbar als PWA (Home-Screen-Icon) - ✅ Installierbar als PWA (Home-Screen-Icon)
- ✅ Web Share Target API (Fotos aus anderer App teilen) - ✅ **Web Share Target** — aus WhatsApp & Co. Bilder **und** Beschreibungstext an „Baustelle" teilen:
anmelden (falls nötig, direkt in der Teilen-Seite), Auftrag suchen und wählen, ablegen. Bilder gehen
über die normale Upload-Queue, der Text wird als Notiz (`notiz_*.txt`) im Auftrag gespeichert
- ✅ Nativer Android-Share-Sheet für Fotos, PDFs und Dokumente (`navigator.share`) — direkt an WhatsApp / Signal / Mail - ✅ Nativer Android-Share-Sheet für Fotos, PDFs und Dokumente (`navigator.share`) — direkt an WhatsApp / Signal / Mail
- ✅ Mehrfachauswahl in Foto- und Dokument-Liste mit Batch-Teilen - ✅ Mehrfachauswahl in Foto- und Dokument-Liste mit Batch-Teilen
- ✅ Android-Hardware-Back-Button schließt Modale/Ansichten sauber (History-Modal-Stack), bricht Mehrfachauswahl ab statt App zu verlassen - ✅ Android-Hardware-Back-Button schließt Modale/Ansichten sauber (History-Modal-Stack), bricht Mehrfachauswahl ab statt App zu verlassen

View file

@ -129,6 +129,28 @@ Absprachen, Maße.
**Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am **Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am
Handy ohne Stift ist das unbrauchbar. Handy ohne Stift ist das unbrauchbar.
## 4. Teilen aus WhatsApp ✅ (2026-08-30)
Bilder und Beschreibungstexte, die die Kundin per WhatsApp schickt, landen direkt im Auftrag.
Vorher endete jeder Teilen-Vorgang in einer Sackgasse:
- `share.html` fragte `api.getToken()` — ein JWT in IndexedDB, das es **seit der SSO-Umstellung
gar nicht mehr gibt** (`api.login()` legt nur noch `user` ab, die Sitzung steckt im
HttpOnly-Cookie `awl_sso`). Ergebnis: immer „Bitte zuerst anmelden", und der Weg „Zur App"
verwarf das Geteilte.
- Der Service Worker sicherte **nur Dateien**. Ein Text-Share (oder Bild + Bildunterschrift)
schrieb nichts in IndexedDB → „Keine geteilten Fotos gefunden".
- [x] Anmeldung über `api/verify.php` (Cookie) statt über das nicht mehr existierende Token
- [x] Anmelden **in** der Teilen-Seite (Passwort + Passkey) — das Geteilte bleibt dabei gesichert
- [x] Service Worker sichert `title`, `text` und `url` mit; Weiterleitung auch ohne Datei
- [x] Auswahl-Seite: Bildvorschau, Betreff + Text zum Nachbessern, Auftragssuche
(ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle)
- [x] Ablegen: Bilder über die normale Upload-Queue (Persist-First), Text als Notiz über
`api/note.php`. Schlägt die Notiz fehl (kein Netz), bleibt der Text gesichert und der
Vorgang ist wiederholbar
- [x] Banner in der App, wenn noch etwas Geteiltes unabgelegt bereitliegt
## Erledigt ## Erledigt
### 2026-08-22 ### 2026-08-22

19
app.css
View file

@ -1468,3 +1468,22 @@ body {
opacity: 0.5; opacity: 0.5;
text-align: right; text-align: right;
} }
/* Hinweis auf noch nicht abgelegte, geteilte Inhalte (Web Share Target) */
.shared-banner {
position: fixed;
left: 16px;
right: 16px;
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
z-index: 60;
padding: 14px 16px;
border: none;
border-radius: 10px;
background: #7aa2f7;
color: #10101a;
font-size: 15px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(0,0,0,0.45);
cursor: pointer;
}
.shared-banner:active { transform: scale(0.98); }

25
app.js
View file

@ -1488,6 +1488,30 @@ router.on('/settings', async () => {
}; };
}); });
/* Aus einem abgebrochenen Teilen-Vorgang koennen noch Inhalte bereitliegen etwa
* weil beim Teilen aus WhatsApp die Anmeldung fehlte oder das Netz weg war. Sie
* bleiben in IndexedDB liegen; ohne diesen Hinweis wuerde sie niemand je wiederfinden. */
async function checkPendingShare() {
if (document.getElementById('shared-banner')) return;
let n = 0, vorhanden = false;
try {
const p = await idb.get('shared_payload');
if (p) { vorhanden = true; n = (p.files || []).length; }
else {
const alt = await idb.get('shared_files'); // altes Format
if (alt && alt.length) { vorhanden = true; n = alt.length; }
}
} catch (_) { return; }
if (!vorhanden) return;
const b = document.createElement('button');
b.id = 'shared-banner';
b.className = 'shared-banner';
b.textContent = '📤 Geteiltes ablegen' + (n ? ' (' + n + ' Bild' + (n > 1 ? 'er' : '') + ')' : '');
b.addEventListener('click', () => { location.href = 'share.html'; });
document.body.appendChild(b);
}
/* Bottom nav + Hilfe-Button */ /* Bottom nav + Hilfe-Button */
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#bottom-nav button').forEach(b => { document.querySelectorAll('#bottom-nav button').forEach(b => {
@ -1496,6 +1520,7 @@ document.addEventListener('DOMContentLoaded', () => {
const helpBtn = document.getElementById('help-btn'); const helpBtn = document.getElementById('help-btn');
if (helpBtn) helpBtn.addEventListener('click', openHelpModal); if (helpBtn) helpBtn.addEventListener('click', openHelpModal);
refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen
checkPendingShare(); // liegt noch etwas Geteiltes bereit?
}); });
/** /**

View file

@ -2,81 +2,346 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Foto teilen — Baustelle</title> <meta name="theme-color" content="#1a1a1f">
<title>Geteiltes ablegen — Baustelle</title>
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<style>
/* Nur für diese Seite — der Rest kommt aus app.css */
.share-section { padding: 12px 16px; border-bottom: 1px solid #2a2a30; }
.share-section h3 { margin: 0 0 8px; font-size: 14px; font-weight: 600; opacity: 0.8; }
.share-note input, .share-note textarea {
width: 100%; background: #25252b; color: #f0f0f0; border: 1px solid #3a3a42;
border-radius: 8px; padding: 12px; font-size: 16px; font-family: inherit;
}
.share-note textarea { min-height: 96px; line-height: 1.5; resize: vertical; margin-top: 8px; }
.order-card.selected { outline: 2px solid #7aa2f7; background: #2f2f35; }
.share-actions {
position: sticky; bottom: 0; background: #1a1a1f; padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #2a2a30;
}
#share-orders { padding: 0 16px 12px; }
</style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<header id="topbar"> <header id="topbar">
<button id="back-btn" class="icon-btn" onclick="location.href='./'"></button> <button id="back-btn" class="icon-btn" title="Zur App"></button>
<h1 id="page-title">📤 Foto teilen</h1> <h1 id="page-title">Geteiltes ablegen</h1>
<span></span> <span id="status-badge">🟢</span>
</header> </header>
<main id="main"> <main id="main"><div class="loader">Geteilte Inhalte werden gelesen…</div></main>
<div id="share-content">
<div class="loader">Foto wird empfangen…</div>
</div>
</main>
</div> </div>
<div id="toast-container"></div>
<script src="lib/idb.js"></script> <script src="lib/idb.js"></script>
<script src="lib/api.js"></script> <script src="lib/api.js"></script>
<script src="lib/offline.js"></script>
<script>window.AWLAUTH_BASE = '/custom/awlauth';</script>
<script src="/custom/awlauth/js/awlauth-webauthn.js"></script>
<script> <script>
/* Web Share Target Handler. /* Empfänger des Web Share Target.
* Browser POST'et das geteilte Foto an diese URL. *
* Wir zeigen eine Auftragsliste, User wählt einen Auftrag → Upload. * Der Service Worker hat den POST aus WhatsApp & Co. schon abgefangen und alles —
* Bilder UND Text — unter 'shared_payload' in IndexedDB gelegt. Hier wird daraus:
* anmelden (falls nötig), Auftrag wählen, ablegen.
*
* Die Anmeldung läuft NICHT über ein Token in IndexedDB (das gibt es seit der
* SSO-Umstellung nicht mehr — genau deshalb kam hier früher immer "Bitte zuerst
* anmelden"), sondern über das awl_sso-Cookie, geprüft an api/verify.php.
*/ */
(async function () { const SHARE_KEY = 'shared_payload';
const ct = document.getElementById('share-content'); const main = () => document.getElementById('main');
const t = await api.getToken();
if (!t) {
ct.innerHTML = '<div class="empty-state"><div class="icon">🔐</div>Bitte zuerst anmelden<br><button class="btn" onclick="location.href=\'./\'">Zur App</button></div>';
return;
}
// Service Worker hat die geteilten Files unter 'shared_files' in IDB abgelegt window.showToast = function (msg, kind) {
const sharedFiles = await idb.get('shared_files'); const t = document.createElement('div');
if (!sharedFiles || !sharedFiles.length) { t.className = 'toast' + (kind ? ' ' + kind : '');
ct.innerHTML = '<div class="empty-state"><div class="icon">📭</div>Keine geteilten Fotos gefunden<br><button class="btn" onclick="location.href=\'./\'">Zur App</button></div>'; t.textContent = msg;
return; document.getElementById('toast-container').appendChild(t);
} setTimeout(() => t.remove(), 3200);
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
document.getElementById('back-btn').addEventListener('click', () => { location.href = './'; });
/* ----- Das geteilte Paket ----- */
let payload = null; // { title, text, url, files:[{name,type,blob}] }
let orders = [];
let selectedId = null;
let searchTimer = null;
function toBlob(f) {
const d = f && f.data;
if (!d) return null;
if (d instanceof Blob) return d; // File ist ein Blob
if (d instanceof ArrayBuffer) return new Blob([d], { type: f.type || 'image/jpeg' });
return null;
}
async function readPayload() {
let p = await idb.get(SHARE_KEY).catch(() => null);
if (!p) {
// Format vor dem 30.08.2026: nur ein Array von Dateien, kein Text
const alt = await idb.get('shared_files').catch(() => null);
if (alt && alt.length) p = { title: '', text: '', url: '', files: alt };
}
if (!p) return null;
p.files = (p.files || [])
.map(f => ({ name: f.name || 'foto.jpg', type: f.type || 'image/jpeg', blob: toBlob(f) }))
.filter(f => f.blob);
p.title = p.title || '';
p.text = p.text || '';
p.url = p.url || '';
return p;
}
/** Was der Nutzer gerade in den Feldern stehen hat — überlebt einen Fehlversuch. */
async function persistPayload() {
const files = payload.files.map(f => ({ name: f.name, type: f.type, data: f.blob }));
await idb.set(SHARE_KEY, {
title: (document.getElementById('note-subject') || {}).value || payload.title,
text: (document.getElementById('note-text') || {}).value || payload.text,
url: '', files, ts: Date.now(),
}).catch(() => {});
}
async function clearPayload() {
await idb.del(SHARE_KEY).catch(() => {});
await idb.del('shared_files').catch(() => {});
}
/* ----- Anmeldung ----- */
async function isLoggedIn() {
try { try {
const data = await api.listOrders({ open: 1 }); await api.request('/verify.php');
const orders = data.orders || []; return true;
ct.innerHTML = ` } catch (e) {
<p class="label" style="padding:0 16px;">${sharedFiles.length} Foto(s) geteilt. Wähle den Auftrag für den Upload:</p> if (e.status === 401) return false;
${orders.map(o => ` throw e; // kein Netz — der Aufrufer zeigt die Offline-Ansicht
<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></div> function renderLogin() {
</div> main().innerHTML = `
`).join('')} <form class="login-form" id="login-form">
<h2>🔧 Anmelden</h2>
<p class="label" style="margin:-12px 0 16px;opacity:.7;text-align:center;">
Die geteilten Inhalte sind gesichert und werden gleich abgelegt.
</p>
<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>
<button type="button" id="bl-passkey" class="btn btn-secondary" style="display:none;margin-top:10px;">
🔑 Mit Fingerabdruck anmelden
</button>
</form>
`; `;
document.querySelectorAll('.order-card').forEach(c => { document.getElementById('login-form').addEventListener('submit', async (e) => {
c.addEventListener('click', async () => { e.preventDefault();
const orderId = c.dataset.id; const fd = new FormData(e.target);
ct.innerHTML = '<div class="loader">Lade hoch…</div>';
for (const f of sharedFiles) {
try { try {
// f.data ist ein File/Blob await api.login(fd.get('login'), fd.get('password'));
await api.uploadOrderPhoto(orderId, f.data, f.name); showToast('Angemeldet');
} catch (err) { console.warn('Upload failed', err); } renderPicker();
} catch (err) {
showToast(err.message || 'Anmeldung fehlgeschlagen', 'error');
} }
await idb.del('shared_files'); });
location.href = './#/orders/' + orderId;
const pk = document.getElementById('bl-passkey');
if (pk && window.awlauthWebAuthn && awlauthWebAuthn.isSupported()) {
pk.style.display = '';
pk.addEventListener('click', async () => {
pk.disabled = true;
try {
const res = await awlauthWebAuthn.login();
if (res && res.user) await idb.set('user', res.user);
showToast('Angemeldet');
renderPicker();
} catch (err) {
pk.disabled = false;
showToast((err && err.message) || 'Anmeldung fehlgeschlagen', 'error');
}
});
}
}
/* ----- Auswahl: Text prüfen, Auftrag wählen ----- */
function defaultSubject() {
// Der Betreff steht spaeter im Dateinamen — deshalb kurz und an der Wortgrenze
const roh = payload.title || (payload.text || '').split('\n').map(s => s.trim()).find(s => s) || '';
if (!roh) return payload.files.length ? 'Vom Kunden geschickt' : '';
if (roh.length <= 45) return roh;
const kurz = roh.slice(0, 45);
const luecke = kurz.lastIndexOf(' ');
return (luecke > 20 ? kurz.slice(0, luecke) : kurz).trim();
}
function defaultText() {
const parts = [];
if (payload.text) parts.push(payload.text);
if (payload.url && payload.text.indexOf(payload.url) === -1) parts.push(payload.url);
return parts.join('\n\n');
}
function renderPicker() {
const n = payload.files.length;
main().innerHTML = `
${n ? `
<div class="share-section">
<h3>${n} Bild${n > 1 ? 'er' : ''}</h3>
<div class="photo-grid" id="share-thumbs"></div>
</div>` : ''}
<div class="share-section share-note">
<h3>Notiz zum Auftrag${n ? ' (optional)' : ''}</h3>
<input type="text" id="note-subject" placeholder="Betreff" value="${esc(defaultSubject())}">
<textarea id="note-text" placeholder="Was ist zu tun / was hat der Kunde geschrieben?">${esc(defaultText())}</textarea>
</div>
<div class="share-section">
<h3>Auftrag wählen</h3>
<div class="search-bar">
<input type="search" id="order-search" placeholder="Auftrag, Kunde oder Ihr Zeichen…" autocomplete="off">
</div>
</div>
<div id="share-orders"><div class="loader">Lade Aufträge…</div></div>
<div class="share-actions">
<button class="btn btn-large" id="btn-store" disabled>Ablegen</button>
</div>
`;
// Vorschau der geteilten Bilder
const grid = document.getElementById('share-thumbs');
if (grid) {
payload.files.forEach(f => {
const d = document.createElement('div');
d.className = 'thumb';
const img = document.createElement('img');
img.src = URL.createObjectURL(f.blob);
img.alt = f.name;
d.appendChild(img);
grid.appendChild(d);
});
}
const search = document.getElementById('order-search');
search.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadOrders(search.value.trim()), 300);
});
document.getElementById('btn-store').addEventListener('click', store);
loadOrders('');
}
async function loadOrders(q) {
const box = document.getElementById('share-orders');
box.innerHTML = '<div class="loader">Lade Aufträge…</div>';
try {
// Ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle — ein älterer
// Auftrag lässt sich sonst nicht mehr treffen.
const data = await api.listOrders(q ? { q } : { open: 1 });
orders = data.orders || [];
if (!orders.length) {
box.innerHTML = '<div class="empty-state"><div class="icon">🔍</div>Kein Auftrag gefunden</div>';
return;
}
box.innerHTML = orders.map(o => `
<div class="order-card${String(o.id) === String(selectedId) ? ' selected' : ''}" data-id="${o.id}">
<div class="ref">${esc(o.ref)}</div>
${o.ref_client ? `<div class="ref-client">${esc(o.ref_client)}</div>` : ''}
<div class="name">${esc((o.customer && o.customer.name) || '')}</div>
<div class="meta"><span>${esc(((o.customer && o.customer.zip) || '') + ' ' + ((o.customer && o.customer.town) || ''))}</span></div>
</div>
`).join('');
box.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => {
selectedId = c.dataset.id;
box.querySelectorAll('.order-card').forEach(x => x.classList.toggle('selected', x === c));
const btn = document.getElementById('btn-store');
btn.disabled = false;
const o = orders.find(x => String(x.id) === String(selectedId));
btn.textContent = 'In ' + ((o && o.ref) || 'Auftrag') + ' ablegen';
}); });
}); });
} catch (e) { } catch (e) {
ct.innerHTML = '<div class="empty-state"><div class="icon"></div>' + e.message + '</div>'; box.innerHTML = '<div class="empty-state"><div class="icon"></div>' + esc(e.message || 'Fehler') + '</div>';
}
}
/* ----- Ablegen ----- */
async function store() {
const btn = document.getElementById('btn-store');
const orderId = selectedId;
const subject = document.getElementById('note-subject').value.trim();
const text = document.getElementById('note-text').value.trim();
btn.disabled = true;
btn.textContent = 'Wird abgelegt…';
// Fotos zuerst in die Upload-Queue — ab da können sie nicht mehr verlorengehen,
// auch nicht bei totem Netz oder wenn das Display ausgeht (Persist-First).
try {
for (let i = 0; i < payload.files.length; i++) {
const f = payload.files[i];
const name = f.name || ('geteilt_' + Date.now() + '_' + i + '.jpg');
await offline.enqueuePhoto(orderId, f.blob, name);
}
} catch (e) {
showToast('Fotos konnten nicht gesichert werden: ' + (e.message || e), 'error');
btn.disabled = false;
btn.textContent = 'Ablegen';
return;
}
payload.files = []; // gesichert — nicht noch einmal einreihen
await persistPayload();
// Notiz braucht das Netz. Klappt sie nicht, bleibt der Text gesichert und der
// Vorgang lässt sich wiederholen — der Text ist dann nicht weg.
if (subject || text) {
try {
await api.saveNote(orderId, { subject: subject || 'Vom Kunden geschickt', text });
} catch (e) {
showToast('Notiz nicht gespeichert (' + (e.message || 'Fehler') + ') — Fotos sind gesichert', 'error');
btn.disabled = false;
btn.textContent = 'Notiz erneut speichern';
const grid = document.getElementById('share-thumbs');
if (grid) grid.closest('.share-section').remove();
return;
}
}
await clearPayload();
await offline.syncQueue().catch(() => {});
showToast('Abgelegt');
location.replace('./#/orders/' + orderId);
}
/* ----- Start ----- */
(async function boot() {
payload = await readPayload();
if (!payload || (!payload.files.length && !payload.text && !payload.title && !payload.url)) {
main().innerHTML = `<div class="empty-state"><div class="icon">📭</div>
Es kam nichts an.<br>Teile Bilder oder Text aus WhatsApp erneut mit „Baustelle".
<br><button class="btn" style="margin-top:16px;" onclick="location.href='./'">Zur App</button></div>`;
return;
}
try {
if (await isLoggedIn()) renderPicker();
else renderLogin();
} catch (e) {
main().innerHTML = `<div class="empty-state"><div class="icon">📴</div>
Kein Netz — die geteilten Inhalte sind gesichert und lassen sich später ablegen.
<br><button class="btn" style="margin-top:16px;" onclick="location.reload()">Nochmal versuchen</button></div>`;
} }
})(); })();
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
</script> </script>
</body> </body>
</html> </html>

81
sw.js
View file

@ -76,40 +76,71 @@ self.addEventListener('activate', (e) => {
); );
}); });
/* Web Share Target: POST auf share.html abfangen */ /* ==========================================================================
async function handleShareTarget(request) { * Web Share Target was aus WhatsApp & Co. geteilt wird, hier annehmen
const fd = await request.formData(); *
let files = []; * Der POST-Body gibt seine Daten genau EINMAL her. Deshalb wird alles sofort in
for (const key of ['photos', 'file', 'files', 'image', 'images']) { * IndexedDB gesichert, bevor irgendetwas angezeigt wird; share.html liest es von dort.
const v = fd.getAll(key); *
if (v && v.length) files = files.concat(v); * Bis 30.08.2026 wurden nur Dateien gesichert. Teilt jemand aus WhatsApp einen
} * Beschreibungstext (mit oder ohne Bild), landete der Text nirgends die Seite
// Fallback: alle File-Entries im FormData durchsuchen * meldete dann "Keine geteilten Fotos gefunden". Titel, Text und Link gehoeren
if (!files.length) { * genauso ins Paket wie die Bilder.
for (const [, v] of fd.entries()) { * ========================================================================== */
if (v && typeof v === 'object' && v.size !== undefined) files.push(v); const SHARE_KEY = 'shared_payload';
}
}
if (files.length) { function shareDb() {
const db = await new Promise((res, rej) => { return new Promise((resolve, reject) => {
const req = indexedDB.open('baustelle-pwa-v1', 1); const req = indexedDB.open('baustelle-pwa-v1', 1);
req.onupgradeneeded = () => { req.onupgradeneeded = () => {
const d = req.result; const d = req.result;
if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv'); if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv');
if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true }); if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
}; };
req.onsuccess = () => res(req.result); req.onsuccess = () => resolve(req.result);
req.onerror = () => rej(req.error); req.onerror = () => reject(req.error);
}); });
const tx = db.transaction('kv', 'readwrite'); }
tx.objectStore('kv').put(
files.map(f => ({ name: f.name || 'photo.jpg', type: f.type || 'image/jpeg', data: f })), function isSharedFile(v) {
'shared_files' return v && typeof v === 'object' && typeof v.arrayBuffer === 'function' && v.size > 0;
); }
await new Promise(res => { tx.oncomplete = res; });
async function handleShareTarget(request) {
try {
const fd = await request.formData();
let files = [];
for (const key of ['photos', 'file', 'files', 'image', 'images']) {
for (const v of fd.getAll(key)) if (isSharedFile(v)) files.push(v);
} }
return Response.redirect('./share.html', 303); // Fallback: manche Apps benennen das Feld anders — alles nehmen, was eine Datei ist
if (!files.length) {
for (const [, v] of fd.entries()) if (isSharedFile(v)) files.push(v);
}
const str = (k) => { const v = fd.get(k); return typeof v === 'string' ? v.trim() : ''; };
const payload = {
title: str('title'),
text: str('text'),
url: str('url'),
files: files.map(f => ({ name: f.name || 'foto.jpg', type: f.type || 'image/jpeg', data: f })),
ts: Date.now(),
};
const db = await shareDb();
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(payload, SHARE_KEY);
tx.objectStore('kv').delete('shared_files'); // altes Format, sonst taucht eine alte Ladung wieder auf
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch (e) {
// Nicht am Sichern haengenbleiben — share.html meldet dann, dass nichts ankam
}
// Absolute Adresse: eine relative wuerde Response.redirect je nach Browser ablehnen
return Response.redirect(new URL('share.html', self.location.href).href, 303);
} }
self.addEventListener('fetch', (e) => { self.addEventListener('fetch', (e) => {