Files
business-card-scanner/static/app.js
Lucas Orth 1f29d5eb27
All checks were successful
Build & Deploy / deploy (push) Successful in 1m14s
effort-Parameter modellabhängig senden, Design-Hierarchie, iOS-Datumsfeld
Fehler: claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab, jede
Karte lief in einen Lesefehler. effort und der server-seitige Fallback
werden jetzt nur an Modelle geschickt, die sie annehmen; ein unbekanntes
Modell mit engerem Parametersatz wird einmal ohne Zusatzparameter
wiederholt, statt die Karte zu verlieren. Tests decken die Zuordnung ab.

Design: Grau kommt als Hierarchiestufe dazu. Drei Ebenen für Fläche
(weiß / grau gefüllt / schwarz), Text (schwarz / --ink-2 / --ink-3) und
Linie (3px / 1px schwarz / graue Haarlinie). Vorher trug alles dieselbe
3px-Kante und dasselbe Schwarz, dadurch war keine Ordnung erkennbar.
Konkret: nicht gewählte Filter treten zurück, Listenzeilen bekommen drei
Textstufen und Statuspillen statt einer gleichförmigen Zeile, das
Kopfband über Listen ist grau hinterlegt, zweitrangige und zerstörende
Aktionen sind leiser als die primäre.

iOS: Datumsfelder zentrieren ihren Wert und sacken in der Höhe ein. Die
WebKit-Pseudoelemente ziehen sie auf die Form der übrigen Felder.

Dazu die Domain in der Dokumentation auf scanner.lucas-orth.de.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:36:24 +02:00

583 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[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 }
);
});
}
/** Zustand einer Karte als abgestufte Pillen: schwarz, was erledigt ist,
* grau umrandet, was noch offen oder auffaellig ist. */
function cardTags(card) {
const tags = [];
if (card.place) tags.push(["", card.place]);
tags.push(
card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"]
);
if (card.extract_status !== "ok") tags.push(["", "Lesefehler"]);
return tags
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
.join("");
}
// ----------------------------------------------------------------- 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) => `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${card.has_contact ? "Gelesen" : "Leer"}</span>
</span>
<span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span>
</button>`
)
.join("");
$("scan-result").innerHTML = `
<div class="section-label"><span class="label">Ergebnis</span></div>
<div class="strip" style="border-top:3px solid #000">
<span class="label">Gefundene Karten</span>
<span class="display d-lg mono-num">${result.count}</span>
</div>
${result.fallback
? `<p class="hint" style="margin-top:10px">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.</p>`
: ""}
<div class="rows" style="margin-top:14px">${rows}</div>`;
}
// ------------------------------------------------------------- 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 = `<div class="empty">
<p class="label">Nichts gefunden</p>
<p class="hint" style="margin:8px 0 0">
${state.query || state.filter !== "alle"
? "Andere Suche oder anderer Filter."
: "Noch keine Karten erfasst."}
</p></div>`;
return;
}
const selectable = state.cards.map((c) => c.id).join(",");
list.innerHTML = `
<div class="strip" style="border-top:3px solid #000">
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
<a class="badge" href="/vcf?ids=${esc(selectable)}" download>Alle als vCard</a>
</div>
<div class="rows">
${state.cards
.map(
(card) => `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
</span>
<span class="row-sub">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</span>
<span class="row-tags">${cardTags(card)}</span>
</button>`
)
.join("")}
</div>`;
}
// ----------------------------------------------------------- Kartendetail
async function loadCard(cardId) {
try {
const card = await api(`/api/cards/${cardId}`);
renderCard(card);
} catch (error) {
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
}
}
function dataTable(card) {
const rows = FIELD_ORDER.filter((field) => card[field])
.map(
(field) =>
`<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`
)
.join("");
return rows
? `<table class="data">${rows}</table>`
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
}
function editForm(card) {
return `<div class="stack-tight">${FIELD_ORDER.map(
(field) => `
<label class="field">
<span class="label">${FIELD_LABELS[field]}</span>
<input data-field="${field}" value="${esc(card[field] || "")}">
</label>`
).join("")}</div>`;
}
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
? `<div class="rows">${card.reminders
.map(
(reminder) => `
<div class="row" style="cursor:default">
<span class="row-head">
<span class="row-title" style="font-size:14px">${esc(reminder.text)}</span>
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
</span>
<span class="row-sub">
${esc(formatDateTime(reminder.due_at))}
${reminder.sent_at ? " · verschickt" : ""}
${reminder.send_error ? " · Versand fehlgeschlagen" : ""}
</span>
</div>`
)
.join("")}</div>`
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
screens.karte.innerHTML = `
<button class="chip" data-back>← Zurück</button>
<div class="block-invert">
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
<p class="muted" style="margin:6px 0 0">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
</span>
<span class="figure">
<span class="label">Ort</span>
<span class="display d-md">${meta.length ? esc(meta[0]) : "Unbekannt"}</span>
</span>
</div>
</div>
${card.image_url
? `<img class="card-image" src="${esc(card.image_url)}" alt="Visitenkarte">`
: ""}
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
In Kontakte speichern <span class="arrow">→</span>
</a>
${card.extract_status !== "ok"
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
: ""}
<div class="section-label">
<span class="label">Daten</span>
</div>
<button class="chip" data-toggle-edit style="margin-bottom:10px">
${state.editing ? "Fertig" : "Bearbeiten"}
</button>
${state.editing ? editForm(card) : dataTable(card)}
<div class="section-label"><span class="label">Notiz</span></div>
<label class="field">
<textarea data-field="notes" placeholder="Worum ging es im Gespräch?">${esc(card.notes || "")}</textarea>
</label>
<div class="section-label"><span class="label">Erinnerung</span></div>
${reminders}
<form class="stack-tight" id="reminder-form" style="margin-top:14px">
<label class="field">
<span class="label">Text</span>
<input name="text" placeholder="Angebot nachfassen" required>
</label>
<label class="field">
<span class="label">Termin</span>
<input name="due" type="datetime-local" required>
</label>
<button class="btn" type="submit">Erinnerung anlegen <span class="arrow">→</span></button>
</form>
<div class="section-label"><span class="label">Karte entfernen</span></div>
<button class="btn btn-quiet" data-delete-card="${esc(card.id)}">
Endgültig löschen <span class="arrow">×</span>
</button>`;
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 = `<div class="empty"><p class="label">Keine Erinnerungen</p>
<p class="hint" style="margin:8px 0 0">Erinnerungen legst du auf einer Karte an.</p></div>`;
return;
}
list.innerHTML = `
${data.mail_configured ? "" : `<p class="hint">Kein SMTP konfiguriert – es wird nichts verschickt.</p>`}
<div class="rows">${data.reminders
.map(
(reminder) => `
<button class="row" data-card="${esc(reminder.card_id)}">
<span class="row-head">
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
<span class="row-meta">${reminder.sent_at ? "verschickt" : "offen"}</span>
</span>
<span class="row-sub">${esc(formatDateTime(reminder.due_at))}
· ${esc(reminder.full_name || reminder.company || "Kontakt")}</span>
</button>`
)
.join("")}</div>`;
} 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();
})();