All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
Auslesen im Hintergrund: /api/scan antwortet, sobald die Karten freigestellt und gespeichert sind, und stößt die Extraktion als Task an. Ein Zwanzigerstapel blockierte vorher den Upload für die ganze Dauer aller Modellaufrufe. Die Oberfläche zeigt "wird gelesen" und lädt nach, solange etwas offen ist. Zwei Folgen davon sind mitbehandelt: Beim Schreiben der Ergebnisse steht COALESCE, damit ein Handeintrag während des Lesens nicht überschrieben wird, und offene Karten werden beim Start nachgeholt, statt dauerhaft in der Warteschlange zu hängen. Bildaufbereitung des Zuschnitts: - Der Einzug zieht die erkannten Ecken um 1,5 % zur Mitte, damit kein Untergrund im Zuschnitt bleibt. - Die Beleuchtung wird ausgeglichen (Division durch eine weichgezeichnete Fassung), damit das Papier weiß wird statt grau. Dunkle Karten bleiben unangetastet - bei ihnen ist das Dunkle das Papier, kein Schatten. - Das Modell meldet die nötige Drehung im Schema; das gespeicherte Bild wird entsprechend gedreht. Geometrisch ist die Lage nicht bestimmbar. Sammelexport entfernt: Der Button "Alle als vCard" ist weg, mit ihm der Endpunkt /vcf sowie vcard.build_many und db.execute_many, die dadurch keinen Aufrufer mehr hatten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
609 lines
20 KiB
JavaScript
609 lines
20 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]);
|
||
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]) => `<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, wird gelesen`
|
||
);
|
||
} 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">wird gelesen</span>
|
||
</span>
|
||
<span class="row-sub">${esc(card.company || card.email || "Zuschnitt gespeichert")}</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();
|
||
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 = `<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(
|
||
(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);
|
||
if (card.extract_status === "offen") {
|
||
state.stats = { ...state.stats, laeuft: 1 };
|
||
schedulePoll();
|
||
}
|
||
} 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 === "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>`
|
||
: ""}
|
||
|
||
<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();
|
||
schedulePoll();
|
||
} 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();
|
||
})();
|