Files
qrcode-generator/src/lib/branding.ts
T
Florian Wilde 4046b72626 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.
2026-08-16 02:18:49 +02:00

144 lines
4.5 KiB
TypeScript

import "server-only";
import { DEFAULT_ACCENT, isHexColor } from "@/lib/branding-colors";
import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module";
export type Branding = {
appName: string;
footerText: string;
accentColor: string;
logoUploadId: string | null;
faviconUploadId: string | null;
/** MIME-Typ des Favicons — der `<link>` braucht ihn. */
faviconMimeType: string | null;
};
export const DEFAULT_BRANDING: Branding = {
appName: "QR-Code-Generator",
footerText: "statische und dynamische QR-Codes",
accentColor: DEFAULT_ACCENT,
logoUploadId: null,
faviconUploadId: null,
faviconMimeType: null,
};
export const MAX_APP_NAME = 60;
export const MAX_FOOTER_TEXT = 120;
/**
* Bewusst **ohne** Zwischenspeicher.
*
* Ein erster Versuch hielt den Wert 15 Sekunden lang fest, um die Abfrage je
* Seitenaufruf zu sparen. Das ging schief: Die API-Route und das Layout
* laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte
* die andere Instanz also gar nicht. Nach dem Speichern blieb die Oberfläche
* unverändert — der schlechteste denkbare Eindruck bei einer Einstellung, die
* man ansieht.
*
* Es bleibt bei einer Abfrage auf den Primärschlüssel einer Tabelle mit einer
* einzigen Zeile. Das ist zu verschmerzen; jede Seite fragt ohnehin mehr ab.
*/
export async function getBranding(): Promise<Branding> {
let wert = DEFAULT_BRANDING;
try {
const zeile = await prisma.branding.findUnique({
where: { id: "branding" },
include: {
faviconUpload: { select: { mimeType: true } },
},
});
if (zeile) {
wert = {
appName: zeile.appName,
footerText: zeile.footerText,
accentColor: isHexColor(zeile.accentColor)
? zeile.accentColor
: DEFAULT_ACCENT,
logoUploadId: zeile.logoUploadId,
faviconUploadId: zeile.faviconUploadId,
faviconMimeType: zeile.faviconUpload?.mimeType ?? null,
};
}
} catch (error) {
// Ein Problem mit der Datenbank darf nicht dazu führen, dass gar keine
// Seite mehr erscheint — dann eben im Auslieferungszustand.
console.error("Erscheinungsbild konnte nicht geladen werden:", error);
}
return wert;
}
export type BrandingUpdate = {
appName?: string;
footerText?: string;
accentColor?: string;
/** `null` entfernt das Bild, `undefined` lässt es unverändert. */
logoUploadId?: string | null;
faviconUploadId?: string | null;
};
/**
* Schreibt das Erscheinungsbild und räumt ersetzte Bilder weg.
*
* Ohne das Aufräumen bliebe jedes einmal hochgeladene Logo für immer in der
* Datenbank liegen: Es gehört zu keinem Code, und die Aufräumroutine für
* herrenlose Dateien lässt „branding" bewusst in Ruhe.
*/
export async function saveBranding(update: BrandingUpdate): Promise<Branding> {
if (update.accentColor !== undefined && !isHexColor(update.accentColor)) {
throw new QrRequestError(
"Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
);
}
const vorher = await prisma.branding.findUnique({
where: { id: "branding" },
select: { logoUploadId: true, faviconUploadId: true },
});
const daten = {
...(update.appName !== undefined ? { appName: update.appName.trim() } : {}),
...(update.footerText !== undefined
? { footerText: update.footerText.trim() }
: {}),
...(update.accentColor !== undefined
? { accentColor: update.accentColor.trim().toUpperCase() }
: {}),
...(update.logoUploadId !== undefined
? { logoUploadId: update.logoUploadId }
: {}),
...(update.faviconUploadId !== undefined
? { faviconUploadId: update.faviconUploadId }
: {}),
};
await prisma.branding.upsert({
where: { id: "branding" },
update: daten,
create: { id: "branding", ...daten },
});
// Die ersetzten Dateien erst danach löschen — solange die neue Zeile nicht
// steht, wird noch die alte gebraucht.
const abgeloest = [
update.logoUploadId !== undefined &&
vorher?.logoUploadId &&
vorher.logoUploadId !== update.logoUploadId &&
vorher.logoUploadId,
update.faviconUploadId !== undefined &&
vorher?.faviconUploadId &&
vorher.faviconUploadId !== update.faviconUploadId &&
vorher.faviconUploadId,
].filter((id): id is string => typeof id === "string");
if (abgeloest.length > 0) {
await prisma.upload
.deleteMany({ where: { id: { in: abgeloest } } })
.catch(() => undefined);
}
return getBranding();
}