Erscheinungsbild im Admin-Bereich anpassbar

Name, Zusatz in der Fusszeile, Akzentfarbe, Logo und Favicon lassen sich
unter Verwaltung -> Erscheinungsbild einstellen. Sie gelten fuer die ganze
Installation und liegen in einer eigenen Tabelle mit genau einer Zeile.

Zur Farbe: Tailwind 4 uebersetzt bg-indigo-600 zu
background-color: var(--color-indigo-600). Die Anwendung schreibt diese
Variablen zur Laufzeit neu — damit folgen alle rund 60 eingefaerbten Stellen
der gewaehlten Farbe, ohne dass eine einzige Klasse angefasst werden muss.

Uebernommen werden nur Farbton und Saettigung, nicht die Helligkeit. Die
Helligkeitskurve stammt aus der Tailwind-Vorlage. Sonst wuerde ein helles
Gelb die Kopfzeile unlesbar machen, denn auf Stufe 600 steht weisse Schrift.
Die Umrechnung laeuft ueber OKLCH und trifft Tailwinds eigenes Indigo exakt.

Logo und Favicon sind Uploads mit eigener Groessengrenze (2 MB bzw. 512 KB)
und eigener Rechtepruefung: instance:manage statt codes:create. Uploads haben
jetzt ein Feld "purpose" — sonst haette die Aufraeumroutine fuer herrenlose
Dateien Logo und Favicon nach einem Tag geloescht. Ein ersetztes Bild wird
gelaescht, und eine Datei aus einem QR-Code laesst sich nicht als Logo
einhaengen.

Auf den Landingpages gelten Farbe und Favicon, aber weder Logo noch
Navigation — wer scannt, soll die Seite des Inhalts sehen.

Ein erster Entwurf hielt das Erscheinungsbild 15 Sekunden im Speicher, um die
Abfrage je Seitenaufruf zu sparen. Das ging schief: API-Route und Layout
laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte
die andere Instanz nicht — nach dem Speichern blieb die Oberflaeche
unveraendert. Jetzt ohne Puffer.
This commit is contained in:
Florian Wilde
2026-08-16 02:18:49 +02:00
parent d0057f038c
commit 4046b72626
16 changed files with 1068 additions and 44 deletions
+377
View File
@@ -0,0 +1,377 @@
"use client";
import { useRef, useState } from "react";
import { Icon } from "@/components/icons";
import {
Button,
Card,
CardTitle,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { buildRamp, isHexColor } from "@/lib/branding-colors";
import { UPLOAD_KINDS, formatBytes } from "@/lib/uploads.shared";
export type BrandingDto = {
appName: string;
footerText: string;
accentColor: string;
logoUploadId: string | null;
faviconUploadId: string | null;
};
/** Vorschläge, damit man nicht mit einem Farbwähler anfangen muss. */
const FARBEN = [
{ label: "Indigo", value: "#4F46E5" },
{ label: "Blau", value: "#1D4ED8" },
{ label: "Türkis", value: "#0D9488" },
{ label: "Grün", value: "#15803D" },
{ label: "Bernstein", value: "#B45309" },
{ label: "Rot", value: "#DC2626" },
{ label: "Pink", value: "#DB2777" },
{ label: "Violett", value: "#7C3AED" },
{ label: "Schiefer", value: "#475569" },
] as const;
type BildFeld = "logoUploadId" | "faviconUploadId";
export function BrandingAdmin({ initial }: { initial: BrandingDto }) {
const [wert, setWert] = useState<BrandingDto>(initial);
const [gespeichert, setGespeichert] = useState<BrandingDto>(initial);
const [fehler, setFehler] = useState<string | null>(null);
const [hinweis, setHinweis] = useState<string | null>(null);
const [laeuft, setLaeuft] = useState(false);
const logoInput = useRef<HTMLInputElement>(null);
const faviconInput = useRef<HTMLInputElement>(null);
const geaendert = JSON.stringify(wert) !== JSON.stringify(gespeichert);
const farbeGueltig = isHexColor(wert.accentColor);
async function hochladen(datei: File, kind: "logo" | "favicon", feld: BildFeld) {
setFehler(null);
setLaeuft(true);
try {
const form = new FormData();
form.set("kind", kind);
form.set("file", datei);
const antwort = await fetch("/api/uploads", { method: "POST", body: form });
const rumpf = await antwort.json().catch(() => null);
if (!antwort.ok) {
throw new Error(rumpf?.error ?? "Die Datei konnte nicht abgelegt werden.");
}
setWert((alt) => ({ ...alt, [feld]: rumpf.upload.id }));
setHinweis(null);
} catch (error) {
setFehler(error instanceof Error ? error.message : String(error));
} finally {
setLaeuft(false);
}
}
async function speichern() {
setFehler(null);
setHinweis(null);
setLaeuft(true);
try {
const antwort = await fetch("/api/admin/branding", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wert),
});
const rumpf = await antwort.json().catch(() => null);
if (!antwort.ok) {
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
}
setGespeichert(rumpf.branding);
setWert(rumpf.branding);
setHinweis(
"Gespeichert. Die Kopfzeile zeigt es beim nächsten Seitenwechsel.",
);
} catch (error) {
setFehler(error instanceof Error ? error.message : String(error));
} finally {
setLaeuft(false);
}
}
const ramp = buildRamp(farbeGueltig ? wert.accentColor : "#4F46E5");
const farbe = (stop: number) =>
ramp.find((eintrag) => eintrag.stop === stop)?.value ?? "";
return (
<div className="space-y-5">
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
<Card>
<CardTitle hint="Steht in der Kopfzeile, im Titel des Browser-Tabs und in der Fußzeile.">
Name
</CardTitle>
<div className="space-y-4">
<Field label="Name der Anwendung" htmlFor="branding-name">
<TextInput
id="branding-name"
value={wert.appName}
maxLength={60}
disabled={laeuft}
onChange={(event) =>
setWert({ ...wert, appName: event.target.value })
}
/>
</Field>
<Field
label="Zusatz in der Fußzeile"
htmlFor="branding-footer"
hint="Steht hinter dem Namen. Leer lassen, wenn nur der Name erscheinen soll."
>
<TextInput
id="branding-footer"
value={wert.footerText}
maxLength={120}
disabled={laeuft}
onChange={(event) =>
setWert({ ...wert, footerText: event.target.value })
}
/>
</Field>
</div>
</Card>
<Card>
<CardTitle hint="Übernommen werden Farbton und Sättigung. Die Helligkeitsstufen bleiben, wie sie sind — sonst würde weiße Schrift auf hellem Grund unlesbar.">
Akzentfarbe
</CardTitle>
<div className="flex flex-wrap gap-2">
{FARBEN.map((eintrag) => (
<button
key={eintrag.value}
type="button"
disabled={laeuft}
onClick={() => setWert({ ...wert, accentColor: eintrag.value })}
aria-label={eintrag.label}
aria-pressed={
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
}
title={eintrag.label}
className={`size-9 rounded-full border-2 transition-transform hover:scale-110 ${
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
? "border-slate-900"
: "border-transparent"
}`}
style={{ backgroundColor: eintrag.value }}
/>
))}
</div>
<div className="mt-4 flex flex-wrap items-end gap-4">
<Field label="Eigener Wert" htmlFor="branding-accent">
<div className="flex items-center gap-2">
<input
type="color"
aria-label="Farbe wählen"
value={farbeGueltig ? wert.accentColor : "#4F46E5"}
disabled={laeuft}
onChange={(event) =>
setWert({ ...wert, accentColor: event.target.value.toUpperCase() })
}
className="size-10 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
/>
<TextInput
id="branding-accent"
value={wert.accentColor}
maxLength={7}
spellCheck={false}
disabled={laeuft}
invalid={!farbeGueltig}
className="w-32 font-mono"
onChange={(event) =>
setWert({ ...wert, accentColor: event.target.value })
}
/>
</div>
</Field>
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
So sieht die Abstufung aus
</span>
<div className="flex overflow-hidden rounded-lg border border-slate-200">
{ramp.map((eintrag) => (
<span
key={eintrag.stop}
title={String(eintrag.stop)}
className="size-7"
style={{ backgroundColor: eintrag.value }}
/>
))}
</div>
</div>
</div>
{!farbeGueltig ? (
<p role="alert" className="mt-2 text-sm text-red-600">
Bitte einen Hex-Wert wie #4F46E5 angeben.
</p>
) : null}
{/* Zeigt, was die Farbe an den auffälligsten Stellen bewirkt. */}
<div className="mt-5 rounded-xl border border-slate-200 p-4">
<span className="mb-3 block text-xs font-semibold tracking-wide text-slate-500 uppercase">
Vorschau
</span>
<div className="flex flex-wrap items-center gap-3">
<span
className="flex size-9 items-center justify-center rounded-lg text-white"
style={{ backgroundColor: farbe(600) }}
>
<Icon name="qr" className="size-5" />
</span>
<span
className="rounded-lg px-4 py-2.5 text-sm font-medium text-white"
style={{ backgroundColor: farbe(600) }}
>
Dynamischen Code speichern
</span>
<span
className="rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: farbe(100), color: farbe(700) }}
>
Abzeichen
</span>
<span className="text-sm font-medium" style={{ color: farbe(600) }}>
Ein Link
</span>
</div>
</div>
</Card>
<Card>
<CardTitle hint="Ohne eigenes Logo erscheint das Zeichen der Anwendung samt Name.">
Logo
</CardTitle>
<BildFeldZeile
vorhanden={wert.logoUploadId}
beschreibung={`PNG, JPEG, WebP oder AVIF, bis ${formatBytes(2 * 1024 * 1024)}. Wird auf 36 px Höhe gezeigt.`}
accept={UPLOAD_KINDS.logo.accept}
inputRef={logoInput}
laeuft={laeuft}
hintergrund="bg-white"
onWaehlen={(datei) => hochladen(datei, "logo", "logoUploadId")}
onEntfernen={() => setWert({ ...wert, logoUploadId: null })}
/>
</Card>
<Card>
<CardTitle hint="Das kleine Bild im Browser-Tab und in den Lesezeichen.">
Favicon
</CardTitle>
<BildFeldZeile
vorhanden={wert.faviconUploadId}
beschreibung={`PNG, ICO oder WebP, bis ${formatBytes(512 * 1024)}. Am besten quadratisch, mindestens 32 × 32 px.`}
accept={UPLOAD_KINDS.favicon.accept}
inputRef={faviconInput}
laeuft={laeuft}
hintergrund="qr-canvas"
onWaehlen={(datei) => hochladen(datei, "favicon", "faviconUploadId")}
onEntfernen={() => setWert({ ...wert, faviconUploadId: null })}
/>
</Card>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void speichern()} disabled={laeuft || !geaendert || !farbeGueltig}>
{laeuft ? "Speichert …" : "Speichern"}
</Button>
{geaendert ? (
<Button
variant="secondary"
disabled={laeuft}
onClick={() => {
setWert(gespeichert);
setFehler(null);
setHinweis(null);
}}
>
Verwerfen
</Button>
) : (
<span className="text-sm text-slate-500">Keine Änderungen offen.</span>
)}
</div>
</div>
);
}
function BildFeldZeile({
vorhanden,
beschreibung,
accept,
inputRef,
laeuft,
hintergrund,
onWaehlen,
onEntfernen,
}: {
vorhanden: string | null;
beschreibung: string;
accept: string;
inputRef: React.RefObject<HTMLInputElement | null>;
laeuft: boolean;
hintergrund: string;
onWaehlen: (datei: File) => void;
onEntfernen: () => void;
}) {
return (
<div className="flex flex-wrap items-center gap-4">
<span
className={`flex h-16 w-24 shrink-0 items-center justify-center rounded-lg border border-slate-200 p-2 ${hintergrund}`}
>
{vorhanden ? (
/* eslint-disable-next-line @next/next/no-img-element -- aus der
Datenbank ausgeliefert */
<img
src={`/api/uploads/${vorhanden}`}
alt="Vorschau"
className="max-h-full max-w-full object-contain"
/>
) : (
<span className="text-xs text-slate-400">keins</span>
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm text-slate-600">{beschreibung}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={laeuft}
onClick={() => inputRef.current?.click()}
>
{vorhanden ? "Ersetzen" : "Datei wählen"}
</Button>
{vorhanden ? (
<Button variant="ghost" size="sm" disabled={laeuft} onClick={onEntfernen}>
Entfernen
</Button>
) : null}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(event) => {
const datei = event.target.files?.[0];
// Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
event.target.value = "";
if (datei) onWaehlen(datei);
}}
/>
</div>
</div>
);
}