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>
347 lines
14 KiB
HTML
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|