/* Visitenkarten-Scanner: Routing, Ansichten, API-Aufrufe. */ (() => { "use strict"; const FIELD_LABELS = { full_name: "Name", first_name: "Vorname", last_name: "Nachname", job_title: "Position", company: "Firma", department: "Abteilung", email: "E-Mail", phone: "Telefon", mobile: "Mobil", fax: "Fax", website: "Web", street: "Straße", postal_code: "PLZ", city: "Ort", country: "Land", }; const FIELD_ORDER = Object.keys(FIELD_LABELS); const state = { route: "scan", cardId: null, query: "", filter: "alle", cards: [], stats: { total: 0, offen: 0, erinnerungen: 0 }, editing: false, }; const $ = (id) => document.getElementById(id); const screens = { scan: $("screen-scan"), karten: $("screen-cards"), karte: $("screen-detail"), erinnerungen: $("screen-reminders"), }; const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); // ------------------------------------------------------------------ Helfer function toast(message) { const node = $("toast"); node.textContent = message; node.hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => { node.hidden = true; }, 3200); } function busy(on, text) { $("busy-text").textContent = text || "Karten werden gelesen"; $("busy-overlay").hidden = !on; } async function api(path, options = {}) { const response = await fetch(path, { credentials: "same-origin", ...options }); if (response.status === 401) { $("login-overlay").hidden = false; throw new Error("Nicht angemeldet"); } if (!response.ok) { let detail = `Fehler ${response.status}`; try { detail = (await response.json()).detail || detail; } catch (_) {} throw new Error(detail); } return response.status === 204 ? null : response.json(); } const formatDate = (iso) => { if (!iso) return "–"; const date = new Date(iso); return Number.isNaN(date.getTime()) ? "–" : date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); }; const formatDateTime = (iso) => { if (!iso) return "–"; const date = new Date(iso); return Number.isNaN(date.getTime()) ? "–" : date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); }; const displayName = (card) => card.full_name || [card.first_name, card.last_name].filter(Boolean).join(" ") || card.company || "Ohne Namen"; /** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind * genauer, deshalb blockiert das hier nichts und scheitert leise. */ function browserPosition() { return new Promise((resolve) => { if (!navigator.geolocation) return resolve(null); navigator.geolocation.getCurrentPosition( (position) => resolve(position.coords), () => resolve(null), { timeout: 6000, maximumAge: 300000 } ); }); } // ----------------------------------------------------------------- Routing function go(route, cardId) { location.hash = cardId ? `#/${route}/${cardId}` : `#/${route}`; } function applyRoute() { const parts = location.hash.replace(/^#\/?/, "").split("/"); const route = screens[parts[0]] ? parts[0] : "scan"; state.route = route; state.cardId = route === "karte" ? parts[1] : null; Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; }); document.querySelectorAll(".tab").forEach((tab) => { const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten"); tab.setAttribute("aria-selected", active ? "true" : "false"); }); window.scrollTo(0, 0); if (route === "karten") loadCards(); if (route === "karte") loadCard(state.cardId); if (route === "erinnerungen") loadReminders(); } // ------------------------------------------------------------------- Scan async function runScan(file) { if (!file) return; busy(true, "Karten werden freigestellt"); const body = new FormData(); body.append("file", file); const coords = await browserPosition(); if (coords) { body.append("lat", coords.latitude); body.append("lon", coords.longitude); } busy(true, "Karten werden gelesen"); try { const result = await api("/api/scan", { method: "POST", body }); renderScanResult(result); await refreshStats(); toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst`); } catch (error) { toast(error.message); } finally { busy(false); } } function renderScanResult(result) { const rows = result.cards .map( (card) => ` ` ) .join(""); $("scan-result").innerHTML = `
Ergebnis
Gefundene Karten ${result.count}
${result.fallback ? `

Kein Kartenrechteck erkannt – das ganze Foto wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger Untergrund mit Abstand zwischen den Karten.

` : ""}
${rows}
`; } // ------------------------------------------------------------- Kartenliste async function loadCards() { const params = new URLSearchParams({ q: state.query, filter: state.filter }); try { const data = await api(`/api/cards?${params}`); state.cards = data.cards; state.stats = data.stats; updateCounters(); renderCards(); } catch (error) { toast(error.message); } } function renderCards() { const list = $("cards-list"); if (!state.cards.length) { list.innerHTML = `

Nichts gefunden

${state.query || state.filter !== "alle" ? "Andere Suche oder anderer Filter." : "Noch keine Karten erfasst."}

`; return; } const selectable = state.cards.map((c) => c.id).join(","); list.innerHTML = `
${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"} Alle als vCard
${state.cards .map( (card) => ` ` ) .join("")}
`; } // ----------------------------------------------------------- Kartendetail async function loadCard(cardId) { try { const card = await api(`/api/cards/${cardId}`); renderCard(card); } catch (error) { screens.karte.innerHTML = `

${esc(error.message)}

`; } } function dataTable(card) { const rows = FIELD_ORDER.filter((field) => card[field]) .map( (field) => `${FIELD_LABELS[field]}${esc(card[field])}` ) .join(""); return rows ? `${rows}
` : `

Keine Felder erkannt. Über „Bearbeiten“ nachtragen.

`; } function editForm(card) { return `
${FIELD_ORDER.map( (field) => ` ` ).join("")}
`; } function renderCard(card) { const meta = []; if (card.place) meta.push(esc(card.place)); if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`); const reminders = card.reminders.length ? `
${card.reminders .map( (reminder) => `
${esc(reminder.text)} ${esc(formatDateTime(reminder.due_at))} ${reminder.sent_at ? " · verschickt" : ""} ${reminder.send_error ? " · Versand fehlgeschlagen" : ""}
` ) .join("")}
` : `

Keine Erinnerung gesetzt.

`; screens.karte.innerHTML = `

${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}

${esc(displayName(card))}

${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}

Erfasst ${esc(formatDate(card.captured_at || card.created_at))} Ort ${meta.length ? esc(meta[0]) : "Unbekannt"}
${card.image_url ? `Visitenkarte` : ""} In Kontakte speichern → ${card.extract_status !== "ok" ? `

Lesefehler: ${esc(card.extract_error || "unbekannt")}

` : ""}
Daten
${state.editing ? editForm(card) : dataTable(card)}
Notiz
Erinnerung
${reminders}
Karte entfernen
`; screens.karte.querySelectorAll("[data-field]").forEach((input) => { input.addEventListener("change", async () => { try { await api(`/api/cards/${card.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [input.dataset.field]: input.value }), }); toast("Gespeichert"); } catch (error) { toast(error.message); } }); }); $("reminder-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const due = form.elements.due.value; if (!due) return; try { await api(`/api/cards/${card.id}/reminders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: form.elements.text.value, due_at: new Date(due).toISOString(), }), }); toast("Erinnerung angelegt"); loadCard(card.id); refreshStats(); } catch (error) { toast(error.message); } }); } // ------------------------------------------------------------ Erinnerungen async function loadReminders() { try { const data = await api("/api/reminders"); const list = $("reminders-list"); if (!data.reminders.length) { list.innerHTML = `

Keine Erinnerungen

Erinnerungen legst du auf einer Karte an.

`; return; } list.innerHTML = ` ${data.mail_configured ? "" : `

Kein SMTP konfiguriert – es wird nichts verschickt.

`}
${data.reminders .map( (reminder) => ` ` ) .join("")}
`; } catch (error) { toast(error.message); } } // -------------------------------------------------------------- Zähler function updateCounters() { $("figure-total").textContent = state.stats.total; $("figure-open").textContent = state.stats.offen; $("tab-count-cards").textContent = state.stats.total; $("tab-count-reminders").textContent = state.stats.erinnerungen; } async function refreshStats() { try { const data = await api("/api/cards?filter=alle"); state.stats = data.stats; updateCounters(); } catch (_) { /* Zähler sind nicht kritisch */ } } // ------------------------------------------------------------- Ereignisse document.addEventListener("click", (event) => { const cardButton = event.target.closest("[data-card]"); if (cardButton) return go("karte", cardButton.dataset.card); if (event.target.closest("[data-back]")) { state.editing = false; return go("karten"); } if (event.target.closest("[data-toggle-edit]")) { state.editing = !state.editing; return loadCard(state.cardId); } const tab = event.target.closest(".tab"); if (tab) return go(tab.dataset.route); const chip = event.target.closest("#filter-chips .chip"); if (chip) { state.filter = chip.dataset.filter; document.querySelectorAll("#filter-chips .chip").forEach((c) => c.setAttribute("aria-pressed", c === chip ? "true" : "false") ); return loadCards(); } const removeReminder = event.target.closest("[data-delete-reminder]"); if (removeReminder) { event.preventDefault(); return api(`/api/reminders/${removeReminder.dataset.deleteReminder}`, { method: "DELETE" }) .then(() => { loadCard(state.cardId); refreshStats(); }) .catch((error) => toast(error.message)); } const removeCard = event.target.closest("[data-delete-card]"); if (removeCard) { if (!confirm("Karte und Bild endgültig löschen?")) return; return api(`/api/cards/${removeCard.dataset.deleteCard}`, { method: "DELETE" }) .then(() => { toast("Karte gelöscht"); go("karten"); }) .catch((error) => toast(error.message)); } }); let searchTimer; $("search-input").addEventListener("input", (event) => { state.query = event.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(loadCards, 220); }); $("search-clear").addEventListener("click", () => { $("search-input").value = ""; state.query = ""; loadCards(); }); ["input-camera", "input-library"].forEach((id) => { $(id).addEventListener("change", (event) => { const file = event.target.files[0]; event.target.value = ""; runScan(file); }); }); $("login-form").addEventListener("submit", async (event) => { event.preventDefault(); const body = new FormData(); body.append("password", $("login-password").value); try { await fetch("/api/login", { method: "POST", body, credentials: "same-origin" }) .then((response) => { if (!response.ok) throw new Error("Passwort stimmt nicht"); }); $("login-overlay").hidden = true; $("login-error").hidden = true; boot(); } catch (error) { $("login-error").textContent = error.message; $("login-error").hidden = false; } }); $("logout-button").addEventListener("click", async () => { await api("/api/logout", { method: "POST" }).catch(() => {}); location.reload(); }); window.addEventListener("hashchange", applyRoute); // --------------------------------------------------------------- Start async function boot() { try { const session = await fetch("/api/session", { credentials: "same-origin" }).then((r) => r.json()); if (session.auth_required && !session.authenticated) { $("login-overlay").hidden = false; return; } $("logout-button").hidden = !session.auth_required; if (!session.extraction_available) { toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten."); } await refreshStats(); applyRoute(); } catch (error) { toast("Server nicht erreichbar"); } } if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch(() => {}); } boot(); })();