Bildeditor, klickbare Felder, Einstellungen; Kopfzeile und Namensfeld raus
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
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>
This commit is contained in:
356
static/app.js
356
static/app.js
@@ -3,7 +3,6 @@
|
||||
"use strict";
|
||||
|
||||
const FIELD_LABELS = {
|
||||
full_name: "Name",
|
||||
first_name: "Vorname",
|
||||
last_name: "Nachname",
|
||||
job_title: "Position",
|
||||
@@ -19,6 +18,7 @@
|
||||
city: "Ort",
|
||||
country: "Land",
|
||||
occasion: "Anlass",
|
||||
place: "Scan-Ort",
|
||||
};
|
||||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
karten: $("screen-cards"),
|
||||
karte: $("screen-detail"),
|
||||
erinnerungen: $("screen-reminders"),
|
||||
mehr: $("screen-mehr"),
|
||||
};
|
||||
|
||||
const esc = (value) =>
|
||||
@@ -103,11 +104,37 @@
|
||||
};
|
||||
|
||||
const displayName = (card) =>
|
||||
card.full_name ||
|
||||
[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";
|
||||
|
||||
@@ -115,11 +142,17 @@
|
||||
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
||||
function browserPosition() {
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.geolocation) return resolve(null);
|
||||
if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" });
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position.coords),
|
||||
() => resolve(null),
|
||||
{ timeout: 6000, maximumAge: 300000 }
|
||||
(position) => resolve({ coords: position.coords }),
|
||||
(error) =>
|
||||
resolve({
|
||||
error:
|
||||
error.code === error.PERMISSION_DENIED
|
||||
? "Standortfreigabe fehlt"
|
||||
: "Standort nicht ermittelbar",
|
||||
}),
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -173,6 +206,7 @@
|
||||
if (route === "karten") loadCards();
|
||||
if (route === "karte") loadCard(state.cardId);
|
||||
if (route === "erinnerungen") loadReminders();
|
||||
if (route === "mehr") renderSettings(state.session || {});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Scan
|
||||
@@ -183,17 +217,21 @@
|
||||
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);
|
||||
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();
|
||||
toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`);
|
||||
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 {
|
||||
@@ -306,12 +344,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 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>`
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -345,6 +409,8 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" };
|
||||
|
||||
const QUICK_DATES = [
|
||||
["In 3 Tagen", 3],
|
||||
["In 1 Woche", 7],
|
||||
@@ -391,17 +457,23 @@
|
||||
<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="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">`
|
||||
? `<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">`
|
||||
? `<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>
|
||||
@@ -554,6 +626,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- 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() {
|
||||
@@ -607,12 +721,200 @@
|
||||
} 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);
|
||||
|
||||
@@ -632,6 +934,15 @@
|
||||
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;
|
||||
@@ -703,11 +1014,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$("logout-button").addEventListener("click", async () => {
|
||||
await api("/api/logout", { method: "POST" }).catch(() => {});
|
||||
location.reload();
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", applyRoute);
|
||||
|
||||
// --------------------------------------------------------------- Start
|
||||
@@ -719,7 +1025,7 @@
|
||||
$("login-overlay").hidden = false;
|
||||
return;
|
||||
}
|
||||
$("logout-button").hidden = !session.auth_required;
|
||||
state.session = session;
|
||||
if (!session.extraction_available) {
|
||||
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
|
||||
}
|
||||
|
||||
@@ -16,21 +16,6 @@
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<span class="mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2">
|
||||
<rect x="2" y="5" width="20" height="14"></rect>
|
||||
<path d="M6 10h5M6 14h8"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="wordmark">
|
||||
<span class="display d-md">Visitenkarten</span>
|
||||
<span class="label">Scanner</span>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="chip" id="logout-button" hidden>Abmelden</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ------------------------------------------------------------- Scan -->
|
||||
<section class="screen stack" id="screen-scan">
|
||||
@@ -87,6 +72,9 @@
|
||||
<!-- ------------------------------------------------------------ Detail -->
|
||||
<section class="screen stack" id="screen-detail" hidden></section>
|
||||
|
||||
<!-- ------------------------------------------------------ Einstellungen -->
|
||||
<section class="screen stack" id="screen-mehr" hidden></section>
|
||||
|
||||
<!-- ------------------------------------------------------ Erinnerungen -->
|
||||
<section class="screen stack" id="screen-reminders" hidden>
|
||||
<div class="section-label"><span class="label">Erinnerungen</span></div>
|
||||
@@ -124,6 +112,15 @@
|
||||
<span class="tab-label">Termine</span>
|
||||
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
|
||||
</button>
|
||||
<button class="tab" role="tab" data-route="mehr" aria-selected="false">
|
||||
<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="3.2"></circle>
|
||||
<path d="M12 3v2.4M12 18.6V21M3 12h2.4M18.6 12H21M5.6 5.6l1.7 1.7M16.7 16.7l1.7 1.7M18.4 5.6l-1.7 1.7M7.3 16.7l-1.7 1.7"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="tab-label">Mehr</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -149,6 +146,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------- Bildbearbeitung -->
|
||||
<div class="overlay" id="editor-overlay" hidden></div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
129
static/style.css
129
static/style.css
@@ -42,7 +42,7 @@ body {
|
||||
}
|
||||
|
||||
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
|
||||
.btn, .chip, .tab, .row, .badge, .pill, .action, .label, .topbar {
|
||||
.btn, .chip, .tab, .row, .badge, .pill, .action, .label {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -95,38 +95,12 @@ a { color: inherit; }
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
background: var(--ink);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.mark svg { width: 19px; height: 19px; }
|
||||
|
||||
.wordmark { line-height: 1; }
|
||||
.wordmark .d-md { display: block; }
|
||||
.wordmark .label { color: var(--ink-3); margin-top: 4px; }
|
||||
|
||||
.topbar .spacer { margin-left: auto; }
|
||||
|
||||
main { flex: 1; display: flex; flex-direction: column; }
|
||||
.screen { flex: 1; padding: var(--pad) var(--pad) 30px; animation: rise 0.18s ease-out; }
|
||||
.screen {
|
||||
flex: 1;
|
||||
padding: calc(var(--pad) + env(safe-area-inset-top)) var(--pad) 30px;
|
||||
animation: rise 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(7px); }
|
||||
@@ -414,6 +388,19 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.data td { font-weight: 600; word-break: break-word; }
|
||||
|
||||
/* Anrufen, schreiben, Route starten - direkt aus der Tabelle. */
|
||||
.data td a {
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.data td a::after {
|
||||
content: " ↗";
|
||||
font-weight: 400;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- Bilder */
|
||||
|
||||
.card-image {
|
||||
@@ -504,6 +491,84 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Bildeditor */
|
||||
|
||||
.editor {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--bg);
|
||||
border: 3px solid var(--ink);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.editor-stage {
|
||||
position: relative;
|
||||
background: var(--fill);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.editor-stage img { width: 100%; display: block; }
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--bg);
|
||||
outline: 9999px solid rgba(0, 0, 0, 0.55);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--ink);
|
||||
}
|
||||
|
||||
.crop-handle[data-corner="nw"] { left: -13px; top: -13px; }
|
||||
.crop-handle[data-corner="ne"] { right: -13px; top: -13px; }
|
||||
.crop-handle[data-corner="se"] { right: -13px; bottom: -13px; }
|
||||
.crop-handle[data-corner="sw"] { left: -13px; bottom: -13px; }
|
||||
|
||||
.editor-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.editor-row .chip { flex: 1; text-align: center; }
|
||||
|
||||
.slider-row { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||||
.slider-row .label { color: var(--ink-3); white-space: nowrap; }
|
||||
.slider-row output { font-weight: 800; font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 3px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--ink);
|
||||
border: 3px solid var(--bg);
|
||||
box-shadow: 0 0 0 1px var(--ink);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 0;
|
||||
background: var(--ink);
|
||||
border: 3px solid var(--bg);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
|
||||
Reference in New Issue
Block a user