Anlass, Dubletten, Rückseite, Teilen; Startseite und App-Anmutung
All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s

Vier Funktionen:

- Anlass: ein Feld auf der Startseite gilt für alle folgenden Scans und
  wird im Browser gemerkt. Ordnet den Bestand verlässlicher als der
  Ortsname aus den Koordinaten. Durchsuchbar, nachträglich änderbar.
- Dubletten: die Detailansicht zeigt Karten, die dieselbe Person meinen
  könnten - gleiche E-Mail, gleiche Telefonnummer (verglichen werden die
  letzten acht Ziffern, damit +49 511 123456 und 0511/123456 aufeinander
  passen) oder gleicher Nachname bei gleicher Firma.
- Rückseite: nachträglich ein zweites Bild zur Karte. Es füllt nur
  Felder, die die Vorderseite offen gelassen hat.
- Schnellwahl bei Erinnerungen: in 3 Tagen / 1 Woche / 2 Wochen /
  1 Monat, jeweils 9 Uhr. Das Datumsfeld braucht man damit selten.

Teilen als vCard über das System-Teilenblatt, mit Download als
Rückfallebene, wo es kein Teilenblatt gibt.

Oberfläche:

- Startseite führt jetzt: Anlass, große Aufnahmefläche mit Symbol statt
  Textzeile, leise Alternative, darunter die zuletzt erfassten Karten.
  Vorher stand dort eine Überschrift mit Zahlen und man musste raten.
- Weniger Webseite, mehr App: kein Seitenrahmen auf dem Telefon, Symbole
  in der Tableiste mit Zählerblasen, kein Tap-Highlight, keine
  Textmarkierung auf Bedienelementen, Safe-Area oben, Einblendung beim
  Ansichtswechsel, Mindesthöhe 54 px für Schaltflächen.
- Die Marken "exportiert" / "nicht exportiert" sind aus der Liste raus;
  dafür gibt es den Filter.

Neue Spalten occasion und back_image_file kommen per ALTER TABLE in
bestehende Datenbanken - CREATE TABLE IF NOT EXISTS rührt eine
vorhandene Tabelle nicht an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-09-06 15:09:08 +02:00
parent a7fbc15792
commit bf0a397597
9 changed files with 660 additions and 149 deletions

View File

@@ -18,6 +18,7 @@
postal_code: "PLZ",
city: "Ort",
country: "Land",
occasion: "Anlass",
};
const FIELD_ORDER = Object.keys(FIELD_LABELS);
@@ -27,7 +28,7 @@
query: "",
filter: "alle",
cards: [],
stats: { total: 0, offen: 0, erinnerungen: 0 },
stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 },
editing: false,
};
@@ -44,6 +45,15 @@
"&": "&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) {
@@ -55,7 +65,7 @@
}
function busy(on, text) {
$("busy-text").textContent = text || "Karten werden gelesen";
$("busy-text").textContent = text || "Karten werden freigestellt";
$("busy-overlay").hidden = !on;
}
@@ -98,6 +108,9 @@
card.company ||
"Ohne Namen";
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() {
@@ -111,24 +124,31 @@
});
}
/** Zustand einer Karte als abgestufte Pillen: schwarz, was erledigt ist,
* grau umrandet, was noch offen oder auffaellig ist. */
/** 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.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"]);
}
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) {
@@ -143,11 +163,13 @@
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");
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();
@@ -160,19 +182,18 @@
busy(true, "Karten werden freigestellt");
const body = new FormData();
body.append("file", file);
body.append("occasion", $("occasion-input").value.trim());
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`
);
loadRecent();
toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`);
} catch (error) {
toast(error.message);
} finally {
@@ -181,31 +202,43 @@
}
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 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:14px">${rows}</div>`;
<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
@@ -224,7 +257,7 @@
}
}
/** Das Auslesen laeuft auf dem Server weiter, nachdem der Scan geantwortet
/** 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);
@@ -232,6 +265,7 @@
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);
}
@@ -254,23 +288,7 @@
<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>`;
<div class="rows">${state.cards.map(cardRow).join("")}</div>`;
}
// ----------------------------------------------------------- Kartendetail
@@ -280,7 +298,7 @@
const card = await api(`/api/cards/${cardId}`);
renderCard(card);
if (card.extract_status === "offen") {
state.stats = { ...state.stats, laeuft: 1 };
state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) };
schedulePoll();
}
} catch (error) {
@@ -290,10 +308,7 @@
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>`
)
.map((field) => `<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`)
.join("");
return rows
? `<table class="data">${rows}</table>`
@@ -310,18 +325,47 @@
).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)}`);
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 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:14px">${esc(reminder.text)}</span>
<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">
@@ -335,14 +379,12 @@
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
screens.karte.innerHTML = `
<button class="chip" data-back>← Zurück</button>
<button class="chip" data-back>← Karten</button>
<div class="block-invert">
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
<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([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</p>
<p class="muted" style="margin:6px 0 0">${esc(subtitle(card))}</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
@@ -350,18 +392,27 @@
</span>
<span class="figure">
<span class="label">Ort</span>
<span class="display d-md">${meta.length ? esc(meta[0]) : "Unbekannt"}</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="Visitenkarte">`
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">`
: ""}
${card.back_image_url
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">`
: `<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>`
@@ -370,9 +421,9 @@
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
: ""}
<div class="section-label">
<span class="label">Daten</span>
</div>
${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>
@@ -390,7 +441,12 @@
<span class="label">Text</span>
<input name="text" placeholder="Angebot nachfassen" required>
</label>
<label class="field">
<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>
@@ -417,9 +473,39 @@
});
});
$("reminder-form").addEventListener("submit", async (event) => {
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 form = event.currentTarget;
const due = form.elements.due.value;
if (!due) return;
try {
@@ -440,6 +526,34 @@
});
}
/** 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);
}
}
// ------------------------------------------------------------ Erinnerungen
async function loadReminders() {
@@ -473,11 +587,15 @@
// -------------------------------------------------------------- Zähler
function badge(id, value) {
const node = $(id);
node.textContent = value;
node.hidden = !value;
}
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;
badge("tab-count-cards", state.stats.total);
badge("tab-count-reminders", state.stats.erinnerungen);
}
async function refreshStats() {
@@ -492,9 +610,15 @@
// ------------------------------------------------------------- Ereignisse
document.addEventListener("click", (event) => {
const shareButton = event.target.closest("[data-share]");
if (shareButton) return shareCard(shareButton.dataset.share);
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");
@@ -547,6 +671,12 @@
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];
@@ -560,10 +690,10 @@
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");
});
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();
@@ -593,7 +723,6 @@
if (!session.extraction_available) {
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
}
await refreshStats();
applyRoute();
} catch (error) {
toast("Server nicht erreichbar");

View File

@@ -34,46 +34,44 @@
<main>
<!-- ------------------------------------------------------------- Scan -->
<section class="screen stack" id="screen-scan">
<div class="block-invert">
<p class="label">Stapel oder Einzelkarte</p>
<h1 class="display d-xl" style="margin-top:8px">Karten<br>erfassen</h1>
<p class="muted" style="margin:12px 0 0">
Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch.
</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
<span class="display d-lg mono-num" id="figure-total">0</span>
</span>
<span class="figure">
<span class="label">Nicht exportiert</span>
<span class="display d-lg mono-num" id="figure-open">0</span>
</span>
</div>
</div>
<label class="field">
<span class="label">Anlass · gilt für die nächsten Scans</span>
<input id="occasion-input" placeholder="z. B. Hannover Messe 2026"
autocomplete="off" enterkeyhint="done">
</label>
<label class="btn btn-primary" for="input-camera">
Foto aufnehmen <span class="arrow">→</span>
<label class="action" for="input-camera">
<span class="action-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
<circle cx="12" cy="13" r="3.6"></circle>
</svg>
</span>
<span class="action-body">
<span class="display d-lg">Foto aufnehmen</span>
<span class="action-hint">
Karten nebeneinander auf einen dunklen Untergrund legen.
Ein Foto reicht für den ganzen Tisch.
</span>
</span>
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
</label>
<label class="btn btn-quiet" for="input-library">
Aus Fotos wählen <span class="arrow">↗</span>
<input id="input-library" type="file" accept="image/*" hidden>
</label>
<p class="hint">
Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
ist das der Unterschied zwischen lesbar und nicht lesbar.
</p>
<div id="scan-result"></div>
<div class="section-label"><span class="label">Zuletzt erfasst</span></div>
<div id="recent-list"></div>
</section>
<!-- ------------------------------------------------------------ Karten -->
<section class="screen stack" id="screen-cards" hidden>
<div class="search">
<input id="search-input" type="search" placeholder="Name, Firma, Ort, Notiz …"
<input id="search-input" type="search" placeholder="Name, Firma, Anlass, Notiz …"
autocomplete="off" enterkeyhint="search">
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
</div>
@@ -97,12 +95,34 @@
</main>
<nav class="tabs" role="tablist">
<button class="tab" role="tab" data-route="scan" aria-selected="true">Scan</button>
<button class="tab" role="tab" data-route="scan" aria-selected="true">
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
<circle cx="12" cy="13" r="3.4"></circle>
</svg>
</span>
<span class="tab-label">Scannen</span>
</button>
<button class="tab" role="tab" data-route="karten" aria-selected="false">
<span class="count mono-num" id="tab-count-cards">0</span>Karten
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<rect x="3" y="6" width="18" height="13"></rect>
<path d="M7 11h5M7 15h8"></path>
</svg>
</span>
<span class="tab-label">Karten</span>
<span class="tab-badge mono-num" id="tab-count-cards" hidden>0</span>
</button>
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="8.5"></circle>
<path d="M12 7v5.2l3.4 2"></path>
</svg>
</span>
<span class="tab-label">Termine</span>
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
</button>
</nav>
</div>
@@ -125,7 +145,7 @@
<div class="overlay" id="busy-overlay" hidden>
<div class="block-invert stack" style="text-align:center">
<div class="spinner" style="margin:0 auto"></div>
<p class="label" id="busy-text">Karten werden gelesen</p>
<p class="label" id="busy-text">Karten werden freigestellt</p>
</div>
</div>

View File

@@ -27,7 +27,7 @@
html, body {
margin: 0;
padding: 0;
background: var(--page);
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 15px;
@@ -35,7 +35,17 @@ html, body {
-webkit-text-size-adjust: 100%;
}
body { overflow-x: hidden; }
body {
overflow-x: hidden;
overscroll-behavior-y: none;
-webkit-tap-highlight-color: transparent;
}
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
.btn, .chip, .tab, .row, .badge, .pill, .action, .label, .topbar {
-webkit-user-select: none;
user-select: none;
}
button, input, textarea, select {
font: inherit;
@@ -81,8 +91,6 @@ a { color: inherit; }
margin: 0 auto;
min-height: 100vh;
background: var(--bg);
border-left: 1px solid var(--ink);
border-right: 1px solid var(--ink);
display: flex;
flex-direction: column;
}
@@ -95,6 +103,7 @@ a { color: inherit; }
align-items: center;
gap: 12px;
padding: 13px var(--pad);
padding-top: calc(13px + env(safe-area-inset-top));
background: var(--bg);
border-bottom: 3px solid var(--ink);
}
@@ -117,7 +126,12 @@ a { color: inherit; }
.topbar .spacer { margin-left: auto; }
main { flex: 1; display: flex; flex-direction: column; }
.screen { flex: 1; padding: var(--pad) var(--pad) 30px; }
.screen { flex: 1; padding: var(--pad) var(--pad) 30px; animation: rise 0.18s ease-out; }
@keyframes rise {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: none; }
}
.screen[hidden] { display: none; }
.stack > * + * { margin-top: 12px; }
@@ -191,6 +205,7 @@ main { flex: 1; display: flex; flex-direction: column; }
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 54px;
padding: 16px;
background: var(--bg);
border: 2px solid var(--ink);
@@ -203,6 +218,24 @@ main { flex: 1; display: flex; flex-direction: column; }
text-align: left;
}
/* Die eine Sache, die man auf der Startseite tut. Grosse Flaeche statt
Textzeile - auf dem Telefon zaehlt die Trefferflaeche, nicht die Kante. */
.action {
display: flex;
align-items: center;
gap: 16px;
padding: 22px 18px;
background: var(--ink);
color: var(--bg);
cursor: pointer;
}
.action:active { background: #1f1f1f; }
.action-icon { flex: 0 0 42px; color: var(--bg); }
.action-icon svg { width: 42px; height: 42px; display: block; }
.action-body { display: flex; flex-direction: column; gap: 7px; }
.action-hint { font-size: 12.5px; line-height: 1.45; color: #a9a9a9; }
/* Was man hier tun soll. */
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
@@ -274,7 +307,7 @@ main { flex: 1; display: flex; flex-direction: column; }
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
.chip {
padding: 7px 11px;
padding: 9px 12px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--ink-3);
@@ -402,15 +435,16 @@ main { flex: 1; display: flex; flex-direction: column; }
}
.tab {
position: relative;
flex: 1;
padding: 12px 4px calc(12px + env(safe-area-inset-bottom));
padding: 10px 4px calc(10px + env(safe-area-inset-bottom));
background: var(--bg);
border: none;
border-left: 1px solid var(--rule);
color: var(--ink-3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
}
@@ -418,15 +452,27 @@ main { flex: 1; display: flex; flex-direction: column; }
.tab:first-child { border-left: none; }
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
.tab .count {
display: block;
font-size: 17px;
.tab-icon { display: block; width: 24px; height: 24px; margin: 0 auto 4px; }
.tab-icon svg { width: 24px; height: 24px; display: block; }
.tab-label { display: block; }
.tab-badge {
position: absolute;
top: 6px;
right: 50%;
transform: translateX(26px);
min-width: 18px;
padding: 1px 4px;
background: var(--ink);
color: var(--bg);
border: 1px solid var(--bg);
font-size: 10px;
font-weight: 800;
color: var(--ink);
margin-bottom: 1px;
letter-spacing: 0;
}
.tab[aria-selected="true"] .count { color: var(--bg); }
.tab-badge[hidden] { display: none; }
.tab[aria-selected="true"] .tab-badge { background: var(--bg); color: var(--ink); border-color: var(--ink); }
/* ------------------------------------------------------------------- Zustaende */
@@ -484,8 +530,11 @@ main { flex: 1; display: flex; flex-direction: column; }
.toast[hidden] { display: none; }
/* Nur am grossen Bildschirm ein Rahmen - auf dem Telefon fuellt die App
den Schirm, wie eine App das tut. */
@media (min-width: 600px) {
:root { --pad: 26px; }
body { background: var(--page); }
.app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
.d-xl { font-size: 52px; }
}