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,112 @@
|
||||
/**
|
||||
* Erzeugt aus einer einzigen Akzentfarbe die volle Abstufung 50–950.
|
||||
*
|
||||
* Warum das reicht, um die ganze Oberfläche umzufärben: Tailwind 4 übersetzt
|
||||
* `bg-indigo-600` zu `background-color: var(--color-indigo-600)`. Wer diese
|
||||
* Variablen zur Laufzeit überschreibt, färbt damit jede der rund 60 Stellen
|
||||
* um — ohne eine einzige Klasse anzufassen.
|
||||
*
|
||||
* Vom gewählten Ton werden nur **Farbton und Sättigung** übernommen, nicht
|
||||
* die Helligkeit. Die Helligkeitskurve stammt aus der Tailwind-Vorlage. Das
|
||||
* ist Absicht: Auf `-600` steht weiße Schrift. Käme die Helligkeit aus der
|
||||
* Eingabe, würde ein helles Gelb die Kopfzeile unlesbar machen.
|
||||
*/
|
||||
|
||||
/** Die Abstufung von Tailwind 4 (indigo) als Vorlage für Helligkeit und Form. */
|
||||
const VORLAGE = [
|
||||
{ stop: 50, l: 96.2, c: 0.018, h: 272.314 },
|
||||
{ stop: 100, l: 93.0, c: 0.034, h: 272.788 },
|
||||
{ stop: 200, l: 87.0, c: 0.065, h: 274.039 },
|
||||
{ stop: 300, l: 78.5, c: 0.115, h: 274.713 },
|
||||
{ stop: 400, l: 67.3, c: 0.182, h: 276.935 },
|
||||
{ stop: 500, l: 58.5, c: 0.233, h: 277.117 },
|
||||
{ stop: 600, l: 51.1, c: 0.262, h: 276.966 },
|
||||
{ stop: 700, l: 45.7, c: 0.24, h: 277.023 },
|
||||
{ stop: 800, l: 39.8, c: 0.195, h: 277.366 },
|
||||
{ stop: 900, l: 35.9, c: 0.144, h: 278.697 },
|
||||
{ stop: 950, l: 25.7, c: 0.09, h: 281.288 },
|
||||
] as const;
|
||||
|
||||
const BEZUG = VORLAGE.find((eintrag) => eintrag.stop === 600)!;
|
||||
|
||||
export const DEFAULT_ACCENT = "#4F46E5";
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** sRGB-Kanal (0–1) in seinen linearen Anteil. */
|
||||
function toLinear(kanal: number): number {
|
||||
return kanal <= 0.04045
|
||||
? kanal / 12.92
|
||||
: Math.pow((kanal + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hex nach OKLCH.
|
||||
*
|
||||
* OKLab ist so gebaut, dass gleiche Zahlenabstände auch gleich große
|
||||
* wahrgenommene Unterschiede sind — anders als HSL, wo dasselbe „50 %
|
||||
* Helligkeit" bei Gelb und Blau völlig verschieden aussieht.
|
||||
*/
|
||||
export function hexToOklch(hex: string): { l: number; c: number; h: number } {
|
||||
const wert = hex.trim().replace("#", "");
|
||||
const r = toLinear(Number.parseInt(wert.slice(0, 2), 16) / 255);
|
||||
const g = toLinear(Number.parseInt(wert.slice(2, 4), 16) / 255);
|
||||
const b = toLinear(Number.parseInt(wert.slice(4, 6), 16) / 255);
|
||||
|
||||
const lMs = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const mMs = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const sMs = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
|
||||
const l = 0.2104542553 * lMs + 0.793617785 * mMs - 0.0040720468 * sMs;
|
||||
const a = 1.9779984951 * lMs - 2.428592205 * mMs + 0.4505937099 * sMs;
|
||||
const bb = 0.0259040371 * lMs + 0.7827717662 * mMs - 0.808675766 * sMs;
|
||||
|
||||
const c = Math.sqrt(a * a + bb * bb);
|
||||
let h = (Math.atan2(bb, a) * 180) / Math.PI;
|
||||
if (h < 0) h += 360;
|
||||
|
||||
return { l: l * 100, c, h };
|
||||
}
|
||||
|
||||
export type ColorRamp = { stop: number; value: string }[];
|
||||
|
||||
/**
|
||||
* Die volle Abstufung zur gewählten Akzentfarbe.
|
||||
*
|
||||
* Die Sättigung wird so skaliert, dass der Ton auf `-600` der Eingabe
|
||||
* entspricht; nach oben begrenzt, damit ein sehr kräftiger Wert die hellen
|
||||
* Stufen nicht in ein grelles Pastell kippt. Browser rechnen Werte außerhalb
|
||||
* des darstellbaren Bereichs selbst zurück.
|
||||
*/
|
||||
export function buildRamp(hex: string): ColorRamp {
|
||||
const gewaehlt = hexToOklch(isHexColor(hex) ? hex : DEFAULT_ACCENT);
|
||||
|
||||
// Ein nahezu grauer Wert soll grau bleiben und nicht auf die Sättigung der
|
||||
// Vorlage hochgezogen werden.
|
||||
const faktor = Math.min(gewaehlt.c / BEZUG.c, 1.25);
|
||||
const drift = (h: number) => (h - BEZUG.h + 540) % 360 - 180;
|
||||
|
||||
return VORLAGE.map(({ stop, l, c, h }) => {
|
||||
const ton = (gewaehlt.h + drift(h) + 360) % 360;
|
||||
const saettigung = Math.max(c * faktor, 0);
|
||||
return {
|
||||
stop,
|
||||
value: `oklch(${l.toFixed(1)}% ${saettigung.toFixed(4)} ${ton.toFixed(3)})`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abstufung als CSS, fertig zum Einsetzen in einen `<style>`-Block.
|
||||
*
|
||||
* Überschrieben wird bewusst die Indigo-Reihe selbst — so wirkt die Farbe
|
||||
* überall dort, wo die Oberfläche sie schon benutzt.
|
||||
*/
|
||||
export function accentCss(hex: string): string {
|
||||
const zeilen = buildRamp(hex)
|
||||
.map(({ stop, value }) => `--color-indigo-${stop}:${value};`)
|
||||
.join("");
|
||||
return `:root{${zeilen}}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -6,6 +6,15 @@
|
||||
* Dateiarten und die Größenformatierung.
|
||||
*/
|
||||
|
||||
/** Bilder, die die Oberfläche selbst anzeigt. */
|
||||
const BILD_TYPEN = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/avif",
|
||||
] as const;
|
||||
|
||||
export const UPLOAD_KINDS = {
|
||||
pdf: {
|
||||
label: "PDF-Dokument",
|
||||
@@ -14,21 +23,52 @@ export const UPLOAD_KINDS = {
|
||||
},
|
||||
image: {
|
||||
label: "Bild",
|
||||
mimeTypes: [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/avif",
|
||||
],
|
||||
mimeTypes: BILD_TYPEN,
|
||||
accept: "image/png,image/jpeg,image/webp,image/gif,image/avif",
|
||||
},
|
||||
logo: {
|
||||
label: "Logo",
|
||||
mimeTypes: BILD_TYPEN,
|
||||
accept: "image/png,image/jpeg,image/webp,image/avif",
|
||||
// Ein Logo in der Kopfzeile ist ein paar Dutzend Kilobyte groß. Die
|
||||
// Grenze soll verhindern, dass jemand ein Foto aus der Kamera hochlädt,
|
||||
// das dann bei jedem Seitenaufruf mitgeht.
|
||||
maxBytes: 2 * 1024 * 1024,
|
||||
},
|
||||
favicon: {
|
||||
label: "Favicon",
|
||||
// Zusätzlich ICO — viele haben genau das noch herumliegen.
|
||||
mimeTypes: [...BILD_TYPEN, "image/x-icon"],
|
||||
accept: "image/png,image/x-icon,.ico,image/webp",
|
||||
maxBytes: 512 * 1024,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Wozu die Datei gehört.
|
||||
*
|
||||
* `code` hängt an einem QR-Code und verschwindet mit ihm; `branding` gehört
|
||||
* zur Oberfläche und darf deshalb nicht als herrenlos weggeräumt werden.
|
||||
*/
|
||||
export const UPLOAD_PURPOSES = ["code", "branding"] as const;
|
||||
export type UploadPurpose = (typeof UPLOAD_PURPOSES)[number];
|
||||
|
||||
export function uploadPurposeOf(kind: UploadKind): UploadPurpose {
|
||||
return kind === "logo" || kind === "favicon" ? "branding" : "code";
|
||||
}
|
||||
|
||||
/** Obergrenze dieser Art — sonst gilt die allgemeine aus MAX_UPLOAD_MB. */
|
||||
export function kindMaxBytes(kind: UploadKind): number | null {
|
||||
const eintrag = UPLOAD_KINDS[kind] as { maxBytes?: number };
|
||||
return eintrag.maxBytes ?? null;
|
||||
}
|
||||
|
||||
export type UploadKind = keyof typeof UPLOAD_KINDS;
|
||||
|
||||
export function isUploadKind(value: unknown): value is UploadKind {
|
||||
return value === "pdf" || value === "image";
|
||||
return (
|
||||
typeof value === "string" && Object.hasOwn(UPLOAD_KINDS, value)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
|
||||
+20
-4
@@ -4,7 +4,9 @@ import { prisma } from "@/lib/db";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
import {
|
||||
formatBytes,
|
||||
kindMaxBytes,
|
||||
UPLOAD_KINDS,
|
||||
uploadPurposeOf,
|
||||
type UploadKind,
|
||||
} from "@/lib/uploads.shared";
|
||||
|
||||
@@ -13,6 +15,8 @@ export {
|
||||
UPLOAD_KINDS,
|
||||
formatBytes,
|
||||
isUploadKind,
|
||||
kindMaxBytes,
|
||||
uploadPurposeOf,
|
||||
} from "@/lib/uploads.shared";
|
||||
export type { UploadKind } from "@/lib/uploads.shared";
|
||||
|
||||
@@ -41,6 +45,10 @@ export function sniffMimeType(bytes: Uint8Array): string | null {
|
||||
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
||||
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
||||
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8
|
||||
// ICO: Reserviert 0, Typ 1, dann die Anzahl der Bilder (mindestens eines).
|
||||
if (starts(0x00, 0x00, 0x01, 0x00) && (bytes[4] | bytes[5]) !== 0) {
|
||||
return "image/x-icon";
|
||||
}
|
||||
|
||||
// RIFF....WEBP
|
||||
if (
|
||||
@@ -102,7 +110,10 @@ export async function storeUpload(
|
||||
file: File,
|
||||
kind: UploadKind,
|
||||
): Promise<StoredUpload> {
|
||||
const limit = maxUploadBytes();
|
||||
// Die engere der beiden Grenzen gewinnt: die allgemeine aus MAX_UPLOAD_MB
|
||||
// und die der Dateiart.
|
||||
const eigene = kindMaxBytes(kind);
|
||||
const limit = eigene === null ? maxUploadBytes() : Math.min(eigene, maxUploadBytes());
|
||||
if (file.size === 0) {
|
||||
throw new QrRequestError("Die Datei ist leer.");
|
||||
}
|
||||
@@ -126,7 +137,9 @@ export async function storeUpload(
|
||||
throw new QrRequestError(
|
||||
kind === "pdf"
|
||||
? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch."
|
||||
: "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
|
||||
: kind === "favicon"
|
||||
? "Das ist kein unterstütztes Bild. Erlaubt sind PNG, ICO, WebP, JPEG, GIF und AVIF."
|
||||
: "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,11 +147,12 @@ export async function storeUpload(
|
||||
data: {
|
||||
filename: sanitizeFilename(
|
||||
file.name,
|
||||
kind === "pdf" ? "dokument.pdf" : "bild",
|
||||
kind === "pdf" ? "dokument.pdf" : kind === "favicon" ? "favicon" : "bild",
|
||||
),
|
||||
mimeType: erkannt,
|
||||
size: bytes.byteLength,
|
||||
data: Buffer.from(bytes),
|
||||
purpose: uploadPurposeOf(kind),
|
||||
},
|
||||
select: { id: true, filename: true, mimeType: true, size: true },
|
||||
});
|
||||
@@ -188,7 +202,9 @@ const ORPHAN_HOURS = 24;
|
||||
export async function cleanupOrphanedUploads(): Promise<number> {
|
||||
const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000);
|
||||
const { count } = await prisma.upload.deleteMany({
|
||||
where: { qrCodeId: null, createdAt: { lt: grenze } },
|
||||
// Nur Dateien, die zu einem Code gehören sollten. Logo und Favicon haben
|
||||
// nie einen Code und wären sonst nach einem Tag verschwunden.
|
||||
where: { qrCodeId: null, purpose: "code", createdAt: { lt: grenze } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user