All checks were successful
Build & Deploy / deploy (push) Successful in 1m14s
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>
583 lines
19 KiB
JavaScript
583 lines
19 KiB
JavaScript
/* 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 }
|
||
);
|
||
});
|
||
}
|
||
|
||
/** 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();
|
||
})();
|