Teilen aus WhatsApp: Bilder UND Text landen jetzt wirklich im Auftrag [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
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:
parent
c2bf4e7c30
commit
707638808c
6 changed files with 451 additions and 87 deletions
|
|
@ -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
|
||||
|
|
|
|||
22
ROADMAP.md
22
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
|
||||
|
|
|
|||
19
app.css
19
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); }
|
||||
|
|
|
|||
25
app.js
25
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?
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
369
share.html
369
share.html
|
|
@ -2,81 +2,346 @@
|
|||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Foto teilen — Baustelle</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a1f">
|
||||
<title>Geteiltes ablegen — Baustelle</title>
|
||||
<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>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<header id="topbar">
|
||||
<button id="back-btn" class="icon-btn" onclick="location.href='./'">←</button>
|
||||
<h1 id="page-title">📤 Foto teilen</h1>
|
||||
<span></span>
|
||||
<button id="back-btn" class="icon-btn" title="Zur App">←</button>
|
||||
<h1 id="page-title">Geteiltes ablegen</h1>
|
||||
<span id="status-badge">🟢</span>
|
||||
</header>
|
||||
<main id="main">
|
||||
<div id="share-content">
|
||||
<div class="loader">Foto wird empfangen…</div>
|
||||
</div>
|
||||
</main>
|
||||
<main id="main"><div class="loader">Geteilte Inhalte werden gelesen…</div></main>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="lib/idb.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>
|
||||
/* Web Share Target Handler.
|
||||
* Browser POST'et das geteilte Foto an diese URL.
|
||||
* Wir zeigen eine Auftragsliste, User wählt einen Auftrag → Upload.
|
||||
/* Empfänger des Web Share Target.
|
||||
*
|
||||
* 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 ct = document.getElementById('share-content');
|
||||
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;
|
||||
}
|
||||
const SHARE_KEY = 'shared_payload';
|
||||
const main = () => document.getElementById('main');
|
||||
|
||||
// Service Worker hat die geteilten Files unter 'shared_files' in IDB abgelegt
|
||||
const sharedFiles = await idb.get('shared_files');
|
||||
if (!sharedFiles || !sharedFiles.length) {
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
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(), 3200);
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 {
|
||||
const data = await api.listOrders({ open: 1 });
|
||||
const orders = data.orders || [];
|
||||
ct.innerHTML = `
|
||||
<p class="label" style="padding:0 16px;">${sharedFiles.length} Foto(s) geteilt. Wähle den Auftrag für den Upload:</p>
|
||||
${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></div>
|
||||
</div>
|
||||
`).join('')}
|
||||
await api.request('/verify.php');
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status === 401) return false;
|
||||
throw e; // kein Netz — der Aufrufer zeigt die Offline-Ansicht
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
main().innerHTML = `
|
||||
<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 => {
|
||||
c.addEventListener('click', async () => {
|
||||
const orderId = c.dataset.id;
|
||||
ct.innerHTML = '<div class="loader">Lade hoch…</div>';
|
||||
for (const f of sharedFiles) {
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
try {
|
||||
// f.data ist ein File/Blob
|
||||
await api.uploadOrderPhoto(orderId, f.data, f.name);
|
||||
} catch (err) { console.warn('Upload failed', err); }
|
||||
await api.login(fd.get('login'), fd.get('password'));
|
||||
showToast('Angemeldet');
|
||||
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) {
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
81
sw.js
81
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) => {
|
||||
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 = () => res(req.result);
|
||||
req.onerror = () => rej(req.error);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
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 })),
|
||||
'shared_files'
|
||||
);
|
||||
await new Promise(res => { tx.oncomplete = res; });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue