feat: Ein Markenlogo je Vertrag

Am Logo findet man den Vertrag schneller als am Namen: Netflix erkennt man am
roten N, nicht am Wort – und erst recht nicht an einem Zahnrad aus der
Symbolauswahl. Die bleibt als Rueckfall fuer alles, wozu es kein Logo gibt.

Hochgeladen wird, was das Telefon hergibt; gespeichert ein PNG von hoechstens
128 Pixeln Kantenlaenge. Aus drei Megabyte werden ein paar Kilobyte, und das
Seitenverhaeltnis bleibt – ein verzerrtes Logo sieht falscher aus als ein
kleines.

Immer PNG, egal was hereinkam. Das erspart der Anzeige jede
Fallunterscheidung und schliesst SVG aus: Eine SVG-Datei kann Skript
enthalten, und ein Bild, das der Nutzer hochlaedt und der Browser ausfuehrt,
ist genau die Art Falle, die man sich nicht in ein Finanzprogramm legt.

Eigene Tabelle, nicht eine Spalte an den Vertraegen: Sonst zoege jede
Listenabfrage saemtliche Bilder mit heraus, obwohl die Liste sie einzeln
nachlaedt.

ZWEI FEHLER IM SICHERN, die dabei aufgefallen sind:

Binaeres liess sich gar nicht sichern – die Ausgabe scheiterte an den Bytes,
und zwar erst beim Schreiben, also nachdem der Nutzer glaubt, ein Backup zu
haben. Jetzt Base64.

Und "Ergaenzen" legte Zeilen von Tabellen mit natuerlichem Schluessel ein
zweites Mal an, statt sie wiederzuerkennen. Beim Logo kippte damit die ganze
Wiederherstellung. Betraf bisher keine Tabelle, weil noch keine solche einen
Vergleichsschluessel hatte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 19:35:37 +02:00
co-authored by Claude Opus 5
parent 0a790fd45a
commit dd2c33725f
12 changed files with 484 additions and 12 deletions
+9
View File
@@ -272,6 +272,15 @@ export const api = {
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
// Das Logo geht als Datei raus, nicht als JSON – deshalb FormData statt body.
uploadSubscriptionLogo: (id, datei) => {
const fd = new FormData();
fd.append("file", datei);
return request(`/subscriptions/${id}/logo`, { method: "PUT", formData: fd });
},
deleteSubscriptionLogo: (id) => request(`/subscriptions/${id}/logo`, { method: "DELETE" }),
subscriptionLogoUrl: (id, version) =>
`/api/subscriptions/${id}/logo${version ? `?v=${version}` : ""}`,
listCategories: () => request("/categories"),
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
+22
View File
@@ -1749,3 +1749,25 @@ tr.row-dup {
.waehrung-kachel.an { border-color: var(--accent, var(--border-strong)); }
.waehrung-kachel input { width: auto; margin: 0; }
.waehrung-kachel:has(input:disabled) { opacity: 0.6; cursor: default; }
/* Logo statt Symbol. Gleiche Kantenlänge wie der IconChip daneben, damit die
Liste nicht wackelt, je nachdem ob ein Vertrag ein Logo hat. */
.abo-logo {
width: 30px;
height: 30px;
object-fit: contain;
border-radius: 6px;
flex: none;
}
.abo-logo.gross { width: 48px; height: 48px; border-radius: 8px; }
.abo-logo-feld {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-2);
}
.abo-logo-knoepfe { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
/* Das Dateifeld steckt in einem <label>, das wie ein Knopf aussieht. */
.abo-logo-knoepfe label.btn { cursor: pointer; margin: 0; }
.abo-logo-knoepfe label.btn.deaktiviert { opacity: 0.5; cursor: not-allowed; }
+6 -1
View File
@@ -366,7 +366,12 @@
"patternHint": "Woran die Abbuchung im Auszug zu erkennen ist. Auf dem Auszug steht selten der Name, unter dem du den Vertrag kennst. Wird noch nicht ausgewertet – kommt mit der automatischen Zuordnung.",
"cancelledFlag": "Gekündigt",
"cancelledFlagHint": "Zählt dann nicht mehr in der Monatssumme, bleibt aber in der Liste stehen.",
"notes": "Notiz"
"notes": "Notiz",
"logo": "Logo",
"logoUpload": "Bild wählen",
"logoRemove": "Entfernen",
"logoHint": "PNG, JPEG, WEBP oder GIF. Wird auf Kachelgrösse gerechnet – lad ruhig das grosse Bild hoch. Solange kein Logo da ist, gilt das Symbol unten.",
"logoAfterSave": "Ein Logo lässt sich anhängen, sobald der Vertrag gespeichert ist. Bis dahin gilt das Symbol unten."
},
"import": {
"title": "Import",
+6 -1
View File
@@ -366,7 +366,12 @@
"patternHint": "How the charge appears on the statement — rarely the name you know it by. Not used yet; it arrives with automatic matching.",
"cancelledFlag": "Cancelled",
"cancelledFlagHint": "Stops counting towards the monthly total but stays in the list.",
"notes": "Note"
"notes": "Note",
"logo": "Logo",
"logoUpload": "Choose image",
"logoRemove": "Remove",
"logoHint": "PNG, JPEG, WEBP or GIF. It is scaled down for you – upload the large one. While there is no logo, the icon below is used.",
"logoAfterSave": "A logo can be attached once the contract is saved. Until then the icon below is used."
},
"import": {
"title": "Import",
+62 -1
View File
@@ -136,6 +136,31 @@ export default function Abos() {
}
}
async function logoWaehlen(datei) {
if (!datei || !editing) return;
setBusy(true);
try {
const aktualisiert = await api.uploadSubscriptionLogo(editing.id, datei);
// Auch den offenen Dialog nachziehen, sonst zeigt er weiter das Symbol.
setEditing(aktualisiert);
laden();
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function logoEntfernen() {
if (!editing) return;
try {
setEditing(await api.deleteSubscriptionLogo(editing.id));
laden();
} catch (e) {
toast(e.message, "error");
}
}
async function loeschen(s) {
const ok = await confirm({
title: t("abos.delete"),
@@ -176,7 +201,14 @@ export default function Abos() {
const ueberfaellig = s.tage_bis_faellig < 0 ? " warn" : "";
return (
<div className={"abo-zeile" + beendet}>
<IconChip name={s.icon} color={s.color} size={30} />
{/* Das Logo schlaegt das Symbol: Netflix erkennt man am roten N,
nicht am Wort – und erst recht nicht an einem Zahnrad. */}
{s.has_logo ? (
<img className="abo-logo" alt=""
src={api.subscriptionLogoUrl(s.id, s.logo_version)} />
) : (
<IconChip name={s.icon} color={s.color} size={30} />
)}
<div className="abo-mitte">
<div className="abo-kopf">
<strong className="ellipsis">{s.name}</strong>
@@ -428,6 +460,35 @@ export default function Abos() {
</label>
<p className="muted small">{t("abos.patternHint")}</p>
<label style={{ marginBottom: 6 }}>
<span>{t("abos.logo")}</span>
</label>
<div className="abo-logo-feld">
{editing?.has_logo ? (
<img className="abo-logo gross" alt=""
src={api.subscriptionLogoUrl(editing.id, editing.logo_version)} />
) : (
<IconChip name={form.icon} color={form.color} size={48} />
)}
<div className="abo-logo-knoepfe">
{/* Ohne gespeicherten Vertrag gibt es noch keine Nummer, an die
das Bild gehoert. Erst speichern, dann Logo. */}
<label className={"btn sm" + (editing ? "" : " deaktiviert")}>
<Icon name="upload" size={14} /> {t("abos.logoUpload")}
<input type="file" accept="image/*" hidden disabled={!editing}
onChange={(e) => logoWaehlen(e.target.files?.[0])} />
</label>
{editing?.has_logo && (
<button type="button" className="btn sm ghost" onClick={logoEntfernen}>
{t("abos.logoRemove")}
</button>
)}
</div>
</div>
<p className="muted small">
{editing ? t("abos.logoHint") : t("abos.logoAfterSave")}
</p>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker