/* 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) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
})[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) =>
`${esc(text)} `;
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]) => ``)
.join("");
}
function cardRow(card) {
return `
${esc(displayName(card))}
${esc(formatDate(card.captured_at || card.created_at))}
${esc(subtitle(card))}
${cardTags(card)}
`;
}
// ----------------------------------------------------------------- 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 = `
Ergebnis
${result.count} Karte${result.count === 1 ? "" : "n"} gefunden
wird gelesen
${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.
`
: ""}
${result.cards.map(cardRow).join("")}
`;
}
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
? `${recent.map(cardRow).join("")}
${data.cards.length > 3
? `
Alle ${data.stats.total} Karten →
`
: ""}`
: `
Noch nichts erfasst
Mach oben ein Foto – der Rest passiert von allein.
`;
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 = `
Nichts gefunden
${state.query || state.filter !== "alle"
? "Andere Suche oder anderer Filter."
: "Noch keine Karten erfasst."}
`;
return;
}
list.innerHTML = `
${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}
${state.stats.laeuft ? `${state.stats.laeuft} werden gelesen ` : ""}
${state.cards.map(cardRow).join("")}
`;
}
// ----------------------------------------------------------- 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 = ``;
}
}
/** 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 `Adresse ${link(NAV_APPS[navApp()].url(postal), postal)} `;
}
if (!card[field]) return "";
return `${FIELD_LABELS[field]} ${fieldCell(card, field)} `;
}).filter(Boolean);
return rows.length
? ``
: `Keine Felder erkannt. Über „Bearbeiten“ nachtragen.
`;
}
function editForm(card) {
return `${FIELD_ORDER.map(
(field) => `
${FIELD_LABELS[field]}
`
).join("")}
`;
}
function duplicateBlock(card) {
if (!card.duplicates || !card.duplicates.length) return "";
return `
Schon einmal erfasst?
${card.duplicates
.map(
(other) => `
${esc(displayName(other))}
${esc(formatDate(other.captured_at || other.created_at))}
${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}
`
)
.join("")}
`;
}
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
? `${card.reminders
.map(
(reminder) => `
${esc(reminder.text)}
Löschen
${esc(formatDateTime(reminder.due_at))}
${reminder.sent_at ? " · verschickt" : ""}
${reminder.send_error ? " · Versand fehlgeschlagen" : ""}
`
)
.join("")}
`
: `Keine Erinnerung gesetzt.
`;
screens.karte.innerHTML = `
← Karten
${esc(card.occasion || (card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"))}
${esc(displayName(card))}
${esc(subtitle(card))}
Erfasst
${esc(formatDate(card.captured_at || card.created_at))}
Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""}
${esc(card.place || "Unbekannt")}
${card.image_url
? `
Bild bearbeiten ✎
`
: ""}
${card.back_image_url
? `
Rückseite bearbeiten ✎
`
: `
Rückseite hinzufügen +
`}
In Kontakte speichern →
Als vCard teilen ↗
${card.extract_status === "offen"
? `Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.
`
: ""}
${card.extract_status === "fehler"
? `Lesefehler: ${esc(card.extract_error || "unbekannt")}
`
: ""}
${duplicateBlock(card)}
Daten
${state.editing ? "Fertig" : "Bearbeiten"}
${state.editing ? editForm(card) : dataTable(card)}
Notiz
Erinnerung
${reminders}
Karte entfernen
Endgültig löschen ×
`;
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 = `
Navigation
Womit Adressen geöffnet werden.
${Object.entries(NAV_APPS)
.map(
([key, app]) =>
`
${app.label}
`
)
.join("")}
Status
Auslesen ${session.extraction_available ? "aktiv" : "kein API-Schlüssel"}
Erinnerungsmails ${session.mail_configured ? "aktiv" : "kein SMTP"}
Anmeldung ${session.auth_required ? "mit Passwort" : "offen"}
${session.auth_required
? `Zugang
Abmelden →
`
: `Zugang
Ohne Passwort erreichbar – APP_PASSWORD in der .env setzen.
`}`;
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 = `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) => `
${esc(reminder.text)}
${reminder.sent_at ? "verschickt" : "offen"}
${esc(formatDateTime(reminder.due_at))}
· ${esc(reminder.full_name || reminder.company || "Kontakt")}
`
)
.join("")}
`;
} 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 = `
${side === "back" ? "Rückseite" : "Vorderseite"} bearbeiten
↺ 90°
↻ 90°
Zuschneiden
Ausrichten
0°
Zum Zuschneiden über dem Bild einen Rahmen aufziehen, Ecken zum Feinjustieren anfassen.
Zurücksetzen
Fertig
`;
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) => ` `)
.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();
})();