Eigenes Logo und Favicon je Installation

Beides ist optional und wird ueber Einstellungen -> Darstellung gesetzt. Ohne
eigenes Bild bleibt alles wie bisher.

Ablage bewusst in der Datenbank (Tabelle branding_assets) und nicht im
Dateisystem: So liegt das Bild automatisch im Backup, und das Deployment
braucht kein zusaetzliches Volume. Es geht um wenige Kilobyte; der Upload ist
auf 512 KB und auf Bildformate begrenzt, die ein Browser auch wirklich
darstellt. Das Abrufen ist ohne Anmeldung moeglich, weil der Browser das
Favicon schon vor dem Login holt - hochladen und entfernen duerfen nur
Administratoren.

Das Logo ersetzt in der Seitenleiste und auf der Anmeldeseite Zeichen und
Schriftzug zusammen. Damit ein zu grosses oder sehr breites Bild das Layout
nicht auseinanderziehen kann, ist die Hoehe per CSS gedeckelt und die Breite
auf den Container begrenzt; object-fit haelt das Seitenverhaeltnis. Faellt das
Laden fehl, erscheint wieder das eingebaute Zeichen.

Als Standard-Favicon dient derselbe Barcode-Glyph wie im App-Icon, damit Web
und iOS-App zusammenpassen.

Getestet: Die Endpunkte sind gegen die laufende API geprueft - hochladen,
abrufen ohne Anmeldung, Abweisen von falschem Dateityp (400), zu grosser Datei
(400), unbekannter Bildart (404) und fehlenden Rechten (401), entfernen und der
Rueckfall auf 404 danach. Web-Build laeuft durch, 40 pytest-Tests gruen. Die
Darstellung im Browser habe ich nicht selbst angesehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 18:02:11 +02:00
parent 30307d6cfb
commit f682993b62
14 changed files with 342 additions and 4 deletions

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { brandingUrl, hasBranding } from "../branding";
import Icon from "./Icon";
/**
* Kopfzeichen der Oberfläche: entweder das hinterlegte Logo (dann ohne
* Schriftzug) oder das eingebaute Zeichen mit dem Namen.
*
* Die Größe des Bildes ist per CSS gedeckelt, damit ein zu großes oder sehr
* breites Logo das Layout nicht auseinanderziehen kann.
*/
export default function BrandMark() {
const [logo, setLogo] = useState(false);
useEffect(() => {
let aktiv = true;
hasBranding("logo").then((da) => aktiv && setLogo(da));
return () => {
aktiv = false;
};
}, []);
if (logo) {
return (
<img
className="brand-logo"
src={brandingUrl("logo")}
alt="Project-Good"
onError={() => setLogo(false)}
/>
);
}
return (
<>
<span className="mark"><Icon name="box" size={18} /></span>
Project-Good
</>
);
}

View File

@@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api";
import { brandingUrl, hasBranding } from "../branding";
import Icon from "./Icon";
/**
* Ein Bild der Installation setzen oder wieder entfernen (Logo bzw. Favicon).
* Ohne eigenes Bild bleibt das eingebaute Zeichen der Oberfläche stehen.
*/
export default function BrandingUpload({ kind, title, hint, onChanged }) {
const [vorhanden, setVorhanden] = useState(false);
const [version, setVersion] = useState(0);
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState(null);
const dateiFeld = useRef(null);
useEffect(() => {
hasBranding(kind).then(setVorhanden);
}, [kind]);
async function auswaehlen(event) {
const datei = event.target.files?.[0];
if (!datei) return;
setFehler(null);
setBusy(true);
try {
await api.uploadBranding(kind, datei);
setVorhanden(true);
setVersion(Date.now());
onChanged?.();
} catch (err) {
setFehler(err.message);
} finally {
setBusy(false);
// Zuruecksetzen, damit dieselbe Datei erneut gewaehlt werden kann.
if (dateiFeld.current) dateiFeld.current.value = "";
}
}
async function entfernen() {
setFehler(null);
setBusy(true);
try {
await api.deleteBranding(kind);
setVorhanden(false);
setVersion(Date.now());
onChanged?.();
} catch (err) {
setFehler(err.message);
} finally {
setBusy(false);
}
}
return (
<div style={{ marginBottom: "var(--sp-4)" }}>
<label style={{ marginBottom: "var(--sp-2)" }}>{title}</label>
<div className="brand-preview">
{vorhanden ? (
<img src={brandingUrl(kind, version)} alt={title} />
) : (
<span className="muted small">Kein eigenes Bild Standard wird verwendet</span>
)}
</div>
<p className="muted small mt-0">{hint}</p>
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
<div className="field-inline">
<input ref={dateiFeld} type="file" onChange={auswaehlen} disabled={busy}
accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif,image/x-icon" />
{vorhanden && (
<button type="button" className="btn ghost" onClick={entfernen} disabled={busy}>
<Icon name="trash" size={14} />Zurücksetzen
</button>
)}
</div>
</div>
);
}