Files
business-card-scanner/static/app.js
Lucas Orth 1e8918e0cd
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
Bildeditor, klickbare Felder, Einstellungen; Kopfzeile und Namensfeld raus
Bildbearbeitung im Nachgang: Drehen in 90-Grad-Schritten, Ausrichten per
Schieberegler, Zuschneiden per Rahmen mit anfassbaren Ecken. Der Server
rechnet jede Korrektur vom unbearbeiteten Original, das beim ersten
Eingriff daneben abgelegt wird - sonst summieren sich die Verluste über
mehrere Korrekturen, und Zurücksetzen wäre nicht möglich. Zwei
Zuschnitte hintereinander werden ineinander verrechnet.

Der Ort ließ sich nicht übernehmen, weil beide Quellen versagen können:
iOS entfernt beim Weitergeben an eine Webseite oft die GPS-Daten aus dem
Foto, und der Browserstandort scheiterte bisher stumm. Jetzt meldet er
seinen Grund ("Standortfreigabe fehlt"), die Detailansicht zeigt die
Herkunft des Ortes, und der Scan-Ort ist von Hand nachtragbar.

E-Mail, Web, Telefon und Mobil sind in der Tabelle jetzt Links; Straße,
PLZ, Ort und Land stehen als eine Adresszeile, die die eingestellte
Navigations-App öffnet.

Einstellungen als vierter Reiter: Navigations-App (Apple Karten, Google
Maps, OpenStreetMap), Statusübersicht und Abmelden - der Knopf saß
vorher in der Kopfzeile.

Die Kopfzeile ist weg. Sie kostete auf dem Telefon eine Bildschirmzeile
und wiederholte nur, was die Tableiste schon sagt.

Kein full_name mehr, nur Vor- und Nachname; akademische Titel gehören in
den Vornamen. Bestehende Datensätze werden bei der Migration am letzten
Leerzeichen aufgeteilt. Die Spalte bleibt ungenutzt stehen: SQLite baut
zum Löschen die ganze Tabelle neu, der Gewinn wären ein paar Byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:19:10 +02:00

1044 lines
36 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 = {
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",
occasion: "Anlass",
place: "Scan-Ort",
};
const FIELD_ORDER = Object.keys(FIELD_LABELS);
const state = {
route: "scan",
cardId: null,
query: "",
filter: "alle",
cards: [],
stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 },
editing: false,
};
const $ = (id) => document.getElementById(id);
const screens = {
scan: $("screen-scan"),
karten: $("screen-cards"),
karte: $("screen-detail"),
erinnerungen: $("screen-reminders"),
mehr: $("screen-mehr"),
};
const esc = (value) =>
String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
const store = {
get(key) {
try { return localStorage.getItem(key) || ""; } catch (_) { return ""; }
},
set(key, value) {
try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ }
},
};
// ------------------------------------------------------------------ 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 freigestellt";
$("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.first_name, card.last_name].filter(Boolean).join(" ") ||
card.company ||
"Ohne Namen";
const NAV_APPS = {
apple: {
label: "Apple Karten",
url: (query) => `https://maps.apple.com/?q=${encodeURIComponent(query)}`,
},
google: {
label: "Google Maps",
url: (query) =>
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`,
},
osm: {
label: "OpenStreetMap",
url: (query) =>
`https://www.openstreetmap.org/search?query=${encodeURIComponent(query)}`,
},
};
const navApp = () => (NAV_APPS[store.get("navApp")] ? store.get("navApp") : "apple");
const address = (card) =>
[card.street, [card.postal_code, card.city].filter(Boolean).join(" "), card.country]
.filter(Boolean)
.join(", ");
const link = (href, text) =>
`<a href="${esc(href)}" rel="noopener">${esc(text)}</a>`;
const subtitle = (card) =>
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
/** 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({ error: "Gerät liefert keinen Standort" });
navigator.geolocation.getCurrentPosition(
(position) => resolve({ coords: position.coords }),
(error) =>
resolve({
error:
error.code === error.PERMISSION_DENIED
? "Standortfreigabe fehlt"
: "Standort nicht ermittelbar",
}),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
);
});
}
/** Zustand einer Karte als Pillen. Ob sie schon exportiert wurde, steht
* bewusst nicht dabei - dafür gibt es den Filter. */
function cardTags(card) {
const tags = [];
if (card.occasion) tags.push(["pill-solid", card.occasion]);
else if (card.place) tags.push(["", card.place]);
if (card.extract_status === "offen") tags.push(["", "wird gelesen"]);
if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]);
return tags
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
.join("");
}
function cardRow(card) {
return `
<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(subtitle(card))}</span>
<span class="row-tags">${cardTags(card)}</span>
</button>`;
}
// ----------------------------------------------------------------- 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 === "scan") loadRecent();
if (route === "karten") loadCards();
if (route === "karte") loadCard(state.cardId);
if (route === "erinnerungen") loadReminders();
if (route === "mehr") renderSettings(state.session || {});
}
// ------------------------------------------------------------------- Scan
async function runScan(file) {
if (!file) return;
busy(true, "Karten werden freigestellt");
const body = new FormData();
body.append("file", file);
body.append("occasion", $("occasion-input").value.trim());
const position = await browserPosition();
if (position.coords) {
body.append("lat", position.coords.latitude);
body.append("lon", position.coords.longitude);
}
try {
const result = await api("/api/scan", { method: "POST", body });
renderScanResult(result);
await refreshStats();
loadRecent();
const ohneOrt = result.cards.every((card) => !card.place);
toast(
`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst` +
(ohneOrt && position.error ? ` · ohne Ort (${position.error})` : ", wird gelesen")
);
} catch (error) {
toast(error.message);
} finally {
busy(false);
}
}
function renderScanResult(result) {
$("scan-result").innerHTML = `
<div class="section-label"><span class="label">Ergebnis</span></div>
<div class="strip">
<span class="label">${result.count} Karte${result.count === 1 ? "" : "n"} gefunden</span>
<span class="pill">wird gelesen</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:12px">
${result.cards.map(cardRow).join("")}
</div>`;
}
async function loadRecent() {
try {
const data = await api("/api/cards?filter=alle");
state.stats = data.stats;
updateCounters();
const recent = data.cards.slice(0, 3);
$("recent-list").innerHTML = recent.length
? `<div class="rows">${recent.map(cardRow).join("")}</div>
${data.cards.length > 3
? `<button class="btn btn-quiet" data-goto="karten" style="margin-top:12px">
Alle ${data.stats.total} Karten <span class="arrow">→</span>
</button>`
: ""}`
: `<div class="empty">
<p class="label">Noch nichts erfasst</p>
<p class="hint" style="margin:8px 0 0">Mach oben ein Foto – der Rest passiert von allein.</p>
</div>`;
schedulePoll();
} catch (error) {
toast(error.message);
}
}
// ------------------------------------------------------------- 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 läuft 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 if (state.route === "scan") loadRecent();
else refreshStats();
}, 3000);
}
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;
}
list.innerHTML = `
<div class="strip">
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
</div>
<div class="rows">${state.cards.map(cardRow).join("")}</div>`;
}
// ----------------------------------------------------------- Kartendetail
async function loadCard(cardId) {
try {
const card = await api(`/api/cards/${cardId}`);
renderCard(card);
if (card.extract_status === "offen") {
state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) };
schedulePoll();
}
} catch (error) {
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
}
}
/** E-Mail, Web, Nummern und Adresse sind Handlungen, keine Zeichenketten. */
function fieldCell(card, field) {
const value = card[field];
if (field === "email") return link(`mailto:${value}`, value);
if (field === "website") {
return link(/^https?:\/\//i.test(value) ? value : `https://${value}`, value);
}
if (["phone", "mobile", "fax"].includes(field)) {
return link(`tel:${value.replace(/[^\d+]/g, "")}`, value);
}
return esc(value);
}
// Straße, PLZ, Ort und Land stehen in der Tabelle als eine Adresszeile.
const DISPLAY_ORDER = [
"first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website", "address", "occasion", "place",
];
function dataTable(card) {
const postal = address(card);
const rows = DISPLAY_ORDER.map((field) => {
if (field === "address") {
if (!postal) return "";
return `<tr><th>Adresse</th><td>${link(NAV_APPS[navApp()].url(postal), postal)}</td></tr>`;
}
if (!card[field]) return "";
return `<tr><th>${FIELD_LABELS[field]}</th><td>${fieldCell(card, field)}</td></tr>`;
}).filter(Boolean);
return rows.length
? `<table class="data">${rows.join("")}</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 duplicateBlock(card) {
if (!card.duplicates || !card.duplicates.length) return "";
return `
<div class="section-label"><span class="label">Schon einmal erfasst?</span></div>
<div class="rows">
${card.duplicates
.map(
(other) => `
<button class="row" data-card="${esc(other.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(other))}</span>
<span class="row-meta">${esc(formatDate(other.captured_at || other.created_at))}</span>
</span>
<span class="row-sub">${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}</span>
</button>`
)
.join("")}
</div>`;
}
const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" };
const QUICK_DATES = [
["In 3 Tagen", 3],
["In 1 Woche", 7],
["In 2 Wochen", 14],
["In 1 Monat", 30],
];
function localInputValue(date) {
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function renderCard(card) {
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:15px">${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>← Karten</button>
<div class="block-invert">
<p class="label">${esc(card.occasion || (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(subtitle(card))}</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${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""}</span>
<span class="display d-md">${esc(card.place || "Unbekannt")}</span>
</span>
</div>
</div>
${card.image_url
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">
<button class="btn btn-quiet" data-edit-image="front">
Bild bearbeiten <span class="arrow">✎</span>
</button>`
: ""}
${card.back_image_url
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">
<button class="btn btn-quiet" data-edit-image="back">
Rückseite bearbeiten <span class="arrow">✎</span>
</button>`
: `<label class="btn btn-quiet" for="input-back">
Rückseite hinzufügen <span class="arrow">+</span>
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
</label>`}
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
In Kontakte speichern <span class="arrow">→</span>
</a>
<button class="btn" data-share="${esc(card.id)}">
Als vCard teilen <span class="arrow">↗</span>
</button>
${card.extract_status === "offen"
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
: ""}
${card.extract_status === "fehler"
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
: ""}
${duplicateBlock(card)}
<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>
<div class="chips" style="margin-top:12px">
${QUICK_DATES.map(
([text, days]) => `<button class="chip" type="button" data-quick="${days}">${text}</button>`
).join("")}
</div>
<label class="field" style="margin-top:12px">
<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);
}
});
});
const backInput = $("input-back");
if (backInput) {
backInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
event.target.value = "";
if (!file) return;
busy(true, "Rückseite wird gelesen");
try {
const body = new FormData();
body.append("file", file);
await api(`/api/cards/${card.id}/back`, { method: "POST", body });
toast("Rückseite hinzugefügt");
loadCard(card.id);
} catch (error) {
toast(error.message);
} finally {
busy(false);
}
});
}
const form = $("reminder-form");
form.querySelectorAll("[data-quick]").forEach((button) => {
button.addEventListener("click", () => {
const date = new Date();
date.setDate(date.getDate() + Number(button.dataset.quick));
date.setHours(9, 0, 0, 0);
form.elements.due.value = localInputValue(date);
});
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
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);
}
});
}
/** Teilen über das System-Teilenblatt. Damit landet die vCard per AirDrop,
* Mail oder Nachricht beim Gegenüber, ohne Umweg über den Download. */
async function shareCard(cardId) {
try {
const response = await fetch(`/vcf/${cardId}`, { credentials: "same-origin" });
if (!response.ok) throw new Error("vCard konnte nicht geladen werden");
const disposition = response.headers.get("content-disposition") || "";
const match = disposition.match(/filename="([^"]+)"/);
const blob = await response.blob();
const file = new File([blob], match ? match[1] : "kontakt.vcf", {
type: "text/vcard",
});
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
// Kein Teilenblatt (Desktop, ältere Browser): dann eben herunterladen.
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
if (error.name !== "AbortError") toast(error.message);
}
}
// ----------------------------------------------------------- Einstellungen
function renderSettings(session) {
const current = navApp();
screens.mehr.innerHTML = `
<div class="section-label"><span class="label">Navigation</span></div>
<p class="hint">Womit Adressen geöffnet werden.</p>
<div class="chips" id="nav-app-chips">
${Object.entries(NAV_APPS)
.map(
([key, app]) =>
`<button class="chip" data-nav-app="${key}" aria-pressed="${key === current}">
${app.label}
</button>`
)
.join("")}
</div>
<div class="section-label"><span class="label">Status</span></div>
<table class="data">
<tr><th>Auslesen</th><td>${session.extraction_available ? "aktiv" : "kein API-Schlüssel"}</td></tr>
<tr><th>Erinnerungsmails</th><td>${session.mail_configured ? "aktiv" : "kein SMTP"}</td></tr>
<tr><th>Anmeldung</th><td>${session.auth_required ? "mit Passwort" : "offen"}</td></tr>
</table>
${session.auth_required
? `<div class="section-label"><span class="label">Zugang</span></div>
<button class="btn btn-quiet" id="logout-button">
Abmelden <span class="arrow">→</span>
</button>`
: `<div class="section-label"><span class="label">Zugang</span></div>
<p class="hint">Ohne Passwort erreichbar – APP_PASSWORD in der .env setzen.</p>`}`;
const logout = $("logout-button");
if (logout) {
logout.addEventListener("click", async () => {
await api("/api/logout", { method: "POST" }).catch(() => {});
location.reload();
});
}
}
// ------------------------------------------------------------ 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 badge(id, value) {
const node = $(id);
node.textContent = value;
node.hidden = !value;
}
function updateCounters() {
badge("tab-count-cards", state.stats.total);
badge("tab-count-reminders", state.stats.erinnerungen);
}
async function refreshStats() {
try {
const data = await api("/api/cards?filter=alle");
state.stats = data.stats;
updateCounters();
schedulePoll();
} catch (_) { /* Zähler sind nicht kritisch */ }
}
// ------------------------------------------------------- Bildbearbeitung
/** Zwei Zuschnitte hintereinander: der zweite bezieht sich auf das Ergebnis
* des ersten, der Server rechnet aber immer vom Original. Also verrechnen. */
function composeCrop(previous, next) {
if (!previous) return next;
return {
x: previous.x + next.x * previous.w,
y: previous.y + next.y * previous.h,
w: next.w * previous.w,
h: next.h * previous.h,
};
}
/** Drehen, ausrichten, zuschneiden. Jede Korrektur wird auf dem Server vom
* unbearbeiteten Original gerechnet - deshalb hält der Editor den ganzen
* Parametersatz und schickt ihn jedes Mal komplett mit. */
function openEditor(cardId, side) {
const overlay = $("editor-overlay");
const source = `/api/cards/${cardId}/image${side === "back" ? "?side=back" : ""}`;
const params = { rotate: 0, angle: 0, crop: null };
let box = null;
let drag = null;
overlay.innerHTML = `
<div class="editor-panel">
<p class="label" style="margin-bottom:12px">
${side === "back" ? "Rückseite" : "Vorderseite"} bearbeiten
</p>
<div class="editor-stage" id="editor-stage"><img id="editor-image" alt=""></div>
<div class="editor-row">
<button class="chip" data-rotate="-90" type="button">↺ 90°</button>
<button class="chip" data-rotate="90" type="button">↻ 90°</button>
<button class="chip" id="editor-crop" type="button" disabled>Zuschneiden</button>
</div>
<div class="slider-row">
<span class="label">Ausrichten</span>
<input type="range" id="editor-angle" min="-20" max="20" step="0.5" value="0">
<output id="editor-angle-out">0°</output>
</div>
<p class="hint" style="margin-top:12px">
Zum Zuschneiden über dem Bild einen Rahmen aufziehen, Ecken zum Feinjustieren anfassen.
</p>
<div class="editor-row">
<button class="chip" id="editor-reset" type="button">Zurücksetzen</button>
<button class="chip" id="editor-done" type="button" aria-pressed="true">Fertig</button>
</div>
</div>`;
overlay.hidden = false;
const stage = $("editor-stage");
const image = $("editor-image");
const cropButton = $("editor-crop");
const reload = () => {
image.src = `${source}${source.includes("?") ? "&" : "?"}t=${Date.now()}`;
};
reload();
function drawBox() {
const existing = stage.querySelector(".crop-box");
if (existing) existing.remove();
cropButton.disabled = !box;
if (!box) return;
const node = document.createElement("div");
node.className = "crop-box";
node.style.left = `${box.x * 100}%`;
node.style.top = `${box.y * 100}%`;
node.style.width = `${box.w * 100}%`;
node.style.height = `${box.h * 100}%`;
node.innerHTML = ["nw", "ne", "se", "sw"]
.map((corner) => `<span class="crop-handle" data-corner="${corner}"></span>`)
.join("");
stage.appendChild(node);
}
function relative(event) {
const bounds = stage.getBoundingClientRect();
return {
x: Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)),
y: Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height)),
};
}
stage.addEventListener("pointerdown", (event) => {
const handle = event.target.closest(".crop-handle");
const point = relative(event);
stage.setPointerCapture(event.pointerId);
if (handle) {
drag = { corner: handle.dataset.corner };
} else {
drag = { corner: null, origin: point };
box = { x: point.x, y: point.y, w: 0, h: 0 };
}
event.preventDefault();
});
stage.addEventListener("pointermove", (event) => {
if (!drag || !box) return;
const point = relative(event);
if (drag.corner) {
const right = box.x + box.w;
const bottom = box.y + box.h;
if (drag.corner.includes("w")) { box.w = right - point.x; box.x = point.x; }
else { box.w = point.x - box.x; }
if (drag.corner.includes("n")) { box.h = bottom - point.y; box.y = point.y; }
else { box.h = point.y - box.y; }
if (box.w < 0) { box.x += box.w; box.w = -box.w; }
if (box.h < 0) { box.y += box.h; box.h = -box.h; }
} else {
box.x = Math.min(drag.origin.x, point.x);
box.y = Math.min(drag.origin.y, point.y);
box.w = Math.abs(point.x - drag.origin.x);
box.h = Math.abs(point.y - drag.origin.y);
}
drawBox();
});
const endDrag = () => {
drag = null;
if (box && (box.w < 0.03 || box.h < 0.03)) box = null;
drawBox();
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
async function apply(extra = {}) {
try {
await api(`/api/cards/${cardId}/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side, ...params, ...extra }),
});
reload();
} catch (error) {
toast(error.message);
}
}
overlay.querySelectorAll("[data-rotate]").forEach((button) => {
button.addEventListener("click", () => {
params.rotate = (params.rotate + Number(button.dataset.rotate) + 360) % 360;
// Der Zuschnitt bezog sich auf die alte Lage und gilt nicht mehr.
params.crop = null;
box = null;
drawBox();
apply();
});
});
let angleTimer;
const angleInput = $("editor-angle");
angleInput.addEventListener("input", () => {
params.angle = Number(angleInput.value);
$("editor-angle-out").textContent = `${params.angle}°`;
clearTimeout(angleTimer);
angleTimer = setTimeout(apply, 350);
});
cropButton.addEventListener("click", () => {
if (!box) return;
params.crop = composeCrop(params.crop, box);
box = null;
drawBox();
apply();
});
$("editor-reset").addEventListener("click", () => {
params.rotate = 0;
params.angle = 0;
params.crop = null;
box = null;
angleInput.value = 0;
$("editor-angle-out").textContent = "0°";
drawBox();
apply({ reset: true });
});
$("editor-done").addEventListener("click", () => {
overlay.hidden = true;
overlay.innerHTML = "";
loadCard(cardId);
});
}
// ------------------------------------------------------------- Ereignisse
document.addEventListener("click", (event) => {
const shareButton = event.target.closest("[data-share]");
if (shareButton) return shareCard(shareButton.dataset.share);
const editImage = event.target.closest("[data-edit-image]");
if (editImage) return openEditor(state.cardId, editImage.dataset.editImage);
const cardButton = event.target.closest("[data-card]");
if (cardButton) return go("karte", cardButton.dataset.card);
const goto = event.target.closest("[data-goto]");
if (goto) return go(goto.dataset.goto);
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 navChip = event.target.closest("[data-nav-app]");
if (navChip) {
store.set("navApp", navChip.dataset.navApp);
document.querySelectorAll("[data-nav-app]").forEach((c) =>
c.setAttribute("aria-pressed", c === navChip ? "true" : "false")
);
return toast(`Adressen öffnen in ${NAV_APPS[navChip.dataset.navApp].label}`);
}
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();
});
// Der Anlass gilt für eine ganze Messe, nicht für einen Scan.
$("occasion-input").value = store.get("occasion");
$("occasion-input").addEventListener("change", (event) => {
store.set("occasion", event.target.value.trim());
});
["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 {
const response = await fetch("/api/login", {
method: "POST", body, credentials: "same-origin",
});
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;
}
});
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;
}
state.session = session;
if (!session.extraction_available) {
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
}
applyRoute();
} catch (error) {
toast("Server nicht erreichbar");
}
}
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch(() => {});
}
boot();
})();