baustelle-pwa/share.html
Eddy 707638808c
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Teilen aus WhatsApp: Bilder UND Text landen jetzt wirklich im Auftrag [deploy]
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>
2026-08-30 18:03:46 +02:00

347 lines
14 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<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" title="Zur App"></button>
<h1 id="page-title">Geteiltes ablegen</h1>
<span id="status-badge">🟢</span>
</header>
<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>
/* 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.
*/
const SHARE_KEY = 'shared_payload';
const main = () => document.getElementById('main');
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 => ({'&':'&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 {
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.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('Angemeldet');
renderPicker();
} catch (err) {
showToast(err.message || 'Anmeldung fehlgeschlagen', 'error');
}
});
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) {
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>`;
}
})();
</script>
</body>
</html>