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:
@@ -0,0 +1,143 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user