/* 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]);
if (card.extract_status === "offen") {
tags.push(["pill-solid", "wird gelesen"]);
} else {
tags.push(
card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"]
);
if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]);
}
return tags
.map(([extra, text]) => `${esc(text)}`)
.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, wird gelesen`
);
} 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();
schedulePoll();
} catch (error) {
toast(error.message);
}
}
/** Das Auslesen laeuft auf dem Server weiter, nachdem der Scan geantwortet
* hat. Solange noch etwas offen ist, holen wir den Stand nach. */
function schedulePoll() {
clearTimeout(schedulePoll.timer);
if (!state.stats.laeuft) return;
schedulePoll.timer = setTimeout(() => {
if (state.route === "karten") loadCards();
else if (state.route === "karte") loadCard(state.cardId);
else refreshStats();
}, 3000);
}
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."}