diff --git a/README.md b/README.md index 8fb8c67..c42ac06 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,9 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und **PWA** - ✅ Service Worker für Offline-Start - ✅ 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 - ✅ 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 diff --git a/ROADMAP.md b/ROADMAP.md index e8a4db3..a3702f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,28 @@ Absprachen, Maße. **Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am 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 ### 2026-08-22 diff --git a/app.css b/app.css index 952a458..4b133a3 100644 --- a/app.css +++ b/app.css @@ -1468,3 +1468,22 @@ body { opacity: 0.5; 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); } diff --git a/app.js b/app.js index 45fc003..556e3c9 100644 --- a/app.js +++ b/app.js @@ -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 */ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('#bottom-nav button').forEach(b => { @@ -1496,6 +1520,7 @@ document.addEventListener('DOMContentLoaded', () => { const helpBtn = document.getElementById('help-btn'); if (helpBtn) helpBtn.addEventListener('click', openHelpModal); refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen + checkPendingShare(); // liegt noch etwas Geteiltes bereit? }); /** diff --git a/share.html b/share.html index eb2c8b7..d1cdbb0 100644 --- a/share.html +++ b/share.html @@ -2,81 +2,346 @@ - -Foto teilen — Baustelle + + +Geteiltes ablegen — Baustelle + +
- -

📤 Foto teilen

- + +

Geteiltes ablegen

+ 🟢
-
-
-
Foto wird empfangen…
-
-
+
Geteilte Inhalte werden gelesen…
+
+ + + + diff --git a/sw.js b/sw.js index 2df93c7..a7d0fcb 100644 --- a/sw.js +++ b/sw.js @@ -76,40 +76,71 @@ self.addEventListener('activate', (e) => { ); }); -/* Web Share Target: POST auf share.html abfangen */ -async function handleShareTarget(request) { - const fd = await request.formData(); - let files = []; - for (const key of ['photos', 'file', 'files', 'image', 'images']) { - const v = fd.getAll(key); - if (v && v.length) files = files.concat(v); - } - // Fallback: alle File-Entries im FormData durchsuchen - if (!files.length) { - for (const [, v] of fd.entries()) { - if (v && typeof v === 'object' && v.size !== undefined) files.push(v); - } - } +/* ========================================================================== + * Web Share Target — was aus WhatsApp & Co. geteilt wird, hier annehmen + * + * Der POST-Body gibt seine Daten genau EINMAL her. Deshalb wird alles sofort in + * IndexedDB gesichert, bevor irgendetwas angezeigt wird; share.html liest es von dort. + * + * Bis 30.08.2026 wurden nur Dateien gesichert. Teilt jemand aus WhatsApp einen + * Beschreibungstext (mit oder ohne Bild), landete der Text nirgends — die Seite + * meldete dann "Keine geteilten Fotos gefunden". Titel, Text und Link gehoeren + * genauso ins Paket wie die Bilder. + * ========================================================================== */ +const SHARE_KEY = 'shared_payload'; - if (files.length) { - const db = await new Promise((res, rej) => { - const req = indexedDB.open('baustelle-pwa-v1', 1); - req.onupgradeneeded = () => { - const d = req.result; - if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv'); - if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true }); - }; - req.onsuccess = () => res(req.result); - req.onerror = () => rej(req.error); - }); +function shareDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open('baustelle-pwa-v1', 1); + req.onupgradeneeded = () => { + const d = req.result; + if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv'); + if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true }); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function isSharedFile(v) { + return v && typeof v === 'object' && typeof v.arrayBuffer === 'function' && v.size > 0; +} + +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); + } + // 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( - files.map(f => ({ name: f.name || 'photo.jpg', type: f.type || 'image/jpeg', data: f })), - 'shared_files' - ); - await new Promise(res => { tx.oncomplete = res; }); + 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 } - return Response.redirect('./share.html', 303); + // 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) => {