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
+2
View File
@@ -27,6 +27,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt. - **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter. - **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst. - **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
Admin-Bereich einstellbar.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`. von selbst; alles Nötige steht in einer `.env`.
+38 -1
View File
@@ -19,6 +19,7 @@ so entschieden ist. Wie man es bedient, steht in der
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen) - [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Single Sign-on](#single-sign-on) - [Single Sign-on](#single-sign-on)
- [Mehrere Domains](#mehrere-domains) - [Mehrere Domains](#mehrere-domains)
- [Erscheinungsbild](#erscheinungsbild)
- [Verwaltung](#verwaltung) - [Verwaltung](#verwaltung)
- [Betrieb](#betrieb) - [Betrieb](#betrieb)
- [Schnittstelle](#schnittstelle) - [Schnittstelle](#schnittstelle)
@@ -344,10 +345,46 @@ Anwendung leiten.
--- ---
## Erscheinungsbild
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
| | |
| --- | --- |
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
benutzbar.
**Auf den Landingpages** gelten Farbe und Favicon, aber weder Logo noch
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
als eigener Punkt auf der [Roadmap](roadmap.md).
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
---
## Verwaltung ## Verwaltung
| Bereich | Was dort geht | | Bereich | Was dort geht |
| --- | --- | | --- | --- |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden | | **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
@@ -398,7 +435,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
| `POST /api/feedback` | Rückmeldung abgeben | | `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern | | `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter | | `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
| `/api/admin/types` · `users` · `groups` · `domains` | Verwaltung | | `/api/admin/types` · `users` · `groups` · `domains` · `branding` | Verwaltung |
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes | | `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage | | `GET /s/{slug}` | öffentliche Landingpage |
+13 -4
View File
@@ -62,6 +62,12 @@ Damit sind **alle 15 vorgesehenen Typen** gebaut.
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
ID-Token. ID-Token.
### Erscheinungsbild (16.08.2026)
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
Umbau der rund 60 eingefärbten Stellen.
### Dokumentation (15.08.2026) ### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
@@ -190,10 +196,13 @@ sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
die Auslieferungsroute — aber auch die Sicherungsstrategie. die Auslieferungsroute — aber auch die Sicherungsstrategie.
**Eigene Gestaltung der Landingpages** · groß **Erscheinungsbild je Gruppe** · groß
Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
braucht Grenzen, damit nichts Unlesbares entsteht. und die Landingpages mit dem Logo der jeweiligen Gruppe zu versehen — damit
eine gescannte Seite nach der Firma aussieht, der sie gehört. Technisch
überschaubar, gestalterisch eine eigene Aufgabe; es braucht Grenzen, damit
nichts Unlesbares entsteht.
**Mehrsprachige Oberfläche** · groß **Mehrsprachige Oberfläche** · groß
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
@@ -0,0 +1,31 @@
-- Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe der Installation.
-- Wozu eine hochgeladene Datei gehört. Bestehende Dateien hängen alle an
-- einem Code, deshalb ist die Voreinstellung richtig.
ALTER TABLE "Upload" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'code';
CREATE INDEX "Upload_purpose_idx" ON "Upload"("purpose");
CREATE TABLE "Branding" (
"id" TEXT NOT NULL DEFAULT 'branding',
"appName" TEXT NOT NULL DEFAULT 'QR-Code-Generator',
"footerText" TEXT NOT NULL DEFAULT 'statische und dynamische QR-Codes',
"accentColor" TEXT NOT NULL DEFAULT '#4F46E5',
"logoUploadId" TEXT,
"faviconUploadId" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Branding_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Branding_logoUploadId_key" ON "Branding"("logoUploadId");
CREATE UNIQUE INDEX "Branding_faviconUploadId_key" ON "Branding"("faviconUploadId");
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_logoUploadId_fkey"
FOREIGN KEY ("logoUploadId") REFERENCES "Upload"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_faviconUploadId_fkey"
FOREIGN KEY ("faviconUploadId") REFERENCES "Upload"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur noch lesen muss.
INSERT INTO "Branding" ("id", "updatedAt") VALUES ('branding', CURRENT_TIMESTAMP);
+31
View File
@@ -219,14 +219,45 @@ model Upload {
data Bytes data Bytes
createdAt DateTime @default(now()) createdAt DateTime @default(now())
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
/// erste Art wird als herrenlos weggeräumt.
purpose String @default("code")
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen, /// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt /// der Code aber noch nicht gespeichert wurde; solche Reste räumt
/// `src/lib/uploads.ts` nach einer Weile weg. /// `src/lib/uploads.ts` nach einer Weile weg.
qrCodeId String? qrCodeId String?
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
brandingLogo Branding? @relation("BrandingLogo")
brandingFavicon Branding? @relation("BrandingFavicon")
@@index([qrCodeId]) @@index([qrCodeId])
@@index([createdAt]) @@index([createdAt])
@@index([purpose])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
model Branding {
id String @id @default("branding")
appName String @default("QR-Code-Generator")
/// Zeile in der Fußzeile, hinter dem Namen.
footerText String @default("statische und dynamische QR-Codes")
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
/// src/lib/branding-colors.ts.
accentColor String @default("#4F46E5")
logoUploadId String? @unique
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
faviconUploadId String? @unique
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
updatedAt DateTime @updatedAt
} }
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes /// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
+19 -3
View File
@@ -4,6 +4,7 @@ import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy"; import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden. // werden.
@@ -21,6 +22,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
const actor = await getActor(); const actor = await getActor();
const signedIn = actor.kind !== "anonymous"; const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage"); const showAdmin = can(actor, "instance:manage");
const branding = await getBranding();
return ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
@@ -30,12 +32,25 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
href="/" href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900" className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
> >
{branding.logoUploadId ? (
/* eslint-disable-next-line @next/next/no-img-element -- aus der
Datenbank ausgeliefert, nicht aus dem Dateisystem */
<img
src={`/api/uploads/${branding.logoUploadId}`}
alt={branding.appName}
className="h-9 w-auto max-w-[12rem] object-contain"
/>
) : (
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white"> <span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" /> <Icon name="qr" className="size-5" />
</span> </span>
)}
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */} Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
<span className="hidden sm:inline">QR-Code-Generator</span> dieses schon den Namen. */}
{branding.logoUploadId ? null : (
<span className="hidden sm:inline">{branding.appName}</span>
)}
</Link> </Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1"> <nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
@@ -108,7 +123,8 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<footer className="border-t border-slate-200 bg-white"> <footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6"> <div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes {branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
</div> </div>
</footer> </footer>
</div> </div>
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BrandingAdmin } from "@/components/branding-admin";
import { Icon } from "@/components/icons";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
export const metadata: Metadata = { title: "Erscheinungsbild" };
export const dynamic = "force-dynamic";
export default async function BrandingPage() {
await requirePagePermission("instance:manage");
const branding = await getBranding();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Erscheinungsbild
</h1>
<p className="mt-2 text-slate-600">
Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen
gelten für die ganze Installation.
</p>
<p className="mt-2 text-sm text-slate-500">
Die öffentlichen Landingpages übernehmen die Farbe und das Favicon,
aber weder Logo noch Navigation — wer einen Code scannt, soll die
Seite des Inhalts sehen und nicht die einer Verwaltungssoftware.
</p>
</div>
<BrandingAdmin
initial={{
appName: branding.appName,
footerText: branding.footerText,
accentColor: branding.accentColor,
logoUploadId: branding.logoUploadId,
faviconUploadId: branding.faviconUploadId,
}}
/>
</div>
);
}
+18 -1
View File
@@ -5,6 +5,7 @@ import { Icon, type IconName } from "@/components/icons";
import { Notice } from "@/components/ui"; import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" }; export const metadata: Metadata = { title: "Verwaltung" };
@@ -45,13 +46,22 @@ function Tile({
export default async function AdminPage() { export default async function AdminPage() {
await requirePagePermission("instance:manage"); await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([ const [userCount, groupCount, codeCount, domainCount, branding] =
await Promise.all([
prisma.user.count(), prisma.user.count(),
prisma.group.count(), prisma.group.count(),
prisma.qrCode.count(), prisma.qrCode.count(),
prisma.domain.count(), prisma.domain.count(),
getBranding(),
]); ]);
const eigenes = [
branding.logoUploadId && "Logo",
branding.faviconUploadId && "Favicon",
].filter(Boolean);
const brandingHint =
eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName;
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
@@ -110,6 +120,13 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen." description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt" hint="15 Typen insgesamt"
/> />
<Tile
href="/verwaltung/erscheinungsbild"
icon="image"
title="Erscheinungsbild"
description="Name, Logo, Favicon und Farbe der Oberfläche."
hint={brandingHint}
/>
</div> </div>
</div> </div>
); );
+96
View File
@@ -0,0 +1,96 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { isHexColor } from "@/lib/branding-colors";
import {
getBranding,
MAX_APP_NAME,
MAX_FOOTER_TEXT,
saveBranding,
} from "@/lib/branding";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/** `null` löscht das Bild, ein fehlendes Feld lässt es unverändert. */
const bildSchema = z.string().trim().min(1).max(64).nullable().optional();
const updateSchema = z.object({
appName: z
.string()
.trim()
.min(1, { error: "Bitte gib einen Namen an." })
.max(MAX_APP_NAME, {
error: `Der Name darf höchstens ${MAX_APP_NAME} Zeichen lang sein.`,
})
.optional(),
footerText: z
.string()
.trim()
.max(MAX_FOOTER_TEXT, {
error: `Der Text darf höchstens ${MAX_FOOTER_TEXT} Zeichen lang sein.`,
})
.optional(),
accentColor: z
.string()
.trim()
.refine(isHexColor, {
error: "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
})
.optional(),
logoUploadId: bildSchema,
faviconUploadId: bildSchema,
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
return Response.json({ branding: await getBranding() });
} catch (error) {
return errorResponse(error);
}
}
/**
* Prüft, dass eine angegebene Datei existiert und wirklich zum
* Erscheinungsbild gehört — sonst ließe sich hier die Datei eines fremden
* QR-Codes einhängen und damit dessen Anzeige zerstören.
*/
async function assertBrandingUpload(
id: string | null | undefined,
feld: string,
): Promise<void> {
if (!id) return;
const upload = await prisma.upload.findUnique({
where: { id },
select: { purpose: true },
});
if (!upload || upload.purpose !== "branding") {
throw new QrRequestError(
`${feld} wurde nicht gefunden. Bitte lade die Datei erneut hoch.`,
404,
);
}
}
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
await assertBrandingUpload(parsed.data.logoUploadId, "Das Logo");
await assertBrandingUpload(parsed.data.faviconUploadId, "Das Favicon");
return Response.json({ branding: await saveBranding(parsed.data) });
} catch (error) {
return errorResponse(error);
}
}
+10 -4
View File
@@ -9,6 +9,7 @@ import {
isUploadKind, isUploadKind,
maxUploadBytes, maxUploadBytes,
storeUpload, storeUpload,
uploadPurposeOf,
} from "@/lib/uploads"; } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -28,10 +29,6 @@ export async function POST(request: NextRequest): Promise<Response> {
const actor = await getActor(); const actor = await getActor();
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
let form: FormData; let form: FormData;
try { try {
form = await request.formData(); form = await request.formData();
@@ -44,6 +41,15 @@ export async function POST(request: NextRequest): Promise<Response> {
return jsonError("Unbekannte Dateiart."); return jsonError("Unbekannte Dateiart.");
} }
// Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon
// gehören der Installation, alles Übrige einem Code in einer Gruppe.
if (uploadPurposeOf(kind) === "branding") {
requirePermission(actor, "instance:manage");
} else {
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
}
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) { if (!(file instanceof File)) {
return jsonError("Es wurde keine Datei mitgeschickt."); return jsonError("Es wurde keine Datei mitgeschickt.");
+44 -6
View File
@@ -1,28 +1,66 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { accentCss } from "@/lib/branding-colors";
import { getBranding } from "@/lib/branding";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { // Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf
// vorgerendert werden.
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding();
return {
title: { title: {
default: "QR-Code-Generator", default: branding.appName,
template: "%s · QR-Code-Generator", template: `%s · ${branding.appName}`,
}, },
description: description:
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.", "QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
...(branding.faviconUploadId
? {
icons: {
icon: {
url: `/api/uploads/${branding.faviconUploadId}`,
type: branding.faviconMimeType ?? undefined,
},
},
}
: {}),
}; };
}
/** /**
* Wurzel-Layout — bewusst nur `html` und `body`. * Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
* *
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in * Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]` * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
* „Meine Codes“ nichts zu suchen. * „Meine Codes“ nichts zu suchen.
*
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
*/ */
export default function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const branding = await getBranding();
return ( return (
<html lang="de" className="h-full"> <html lang="de" className="h-full">
<body className="flex min-h-full flex-col">{children}</body> <body className="flex min-h-full flex-col">
{/*
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
die Voreinstellung zurück.
*/}
<style
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
/>
{children}
</body>
</html> </html>
); );
} }
+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>
);
}
+112
View File
@@ -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}}`;
}
+143
View File
@@ -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();
}
+48 -8
View File
@@ -6,6 +6,15 @@
* Dateiarten und die Größenformatierung. * 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 = { export const UPLOAD_KINDS = {
pdf: { pdf: {
label: "PDF-Dokument", label: "PDF-Dokument",
@@ -14,21 +23,52 @@ export const UPLOAD_KINDS = {
}, },
image: { image: {
label: "Bild", label: "Bild",
mimeTypes: [ mimeTypes: BILD_TYPEN,
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/avif",
],
accept: "image/png,image/jpeg,image/webp,image/gif,image/avif", 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; } 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 type UploadKind = keyof typeof UPLOAD_KINDS;
export function isUploadKind(value: unknown): value is UploadKind { 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 { export function formatBytes(bytes: number): string {
+19 -3
View File
@@ -4,7 +4,9 @@ import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
import { import {
formatBytes, formatBytes,
kindMaxBytes,
UPLOAD_KINDS, UPLOAD_KINDS,
uploadPurposeOf,
type UploadKind, type UploadKind,
} from "@/lib/uploads.shared"; } from "@/lib/uploads.shared";
@@ -13,6 +15,8 @@ export {
UPLOAD_KINDS, UPLOAD_KINDS,
formatBytes, formatBytes,
isUploadKind, isUploadKind,
kindMaxBytes,
uploadPurposeOf,
} from "@/lib/uploads.shared"; } from "@/lib/uploads.shared";
export type { UploadKind } 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(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8 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 // RIFF....WEBP
if ( if (
@@ -102,7 +110,10 @@ export async function storeUpload(
file: File, file: File,
kind: UploadKind, kind: UploadKind,
): Promise<StoredUpload> { ): 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) { if (file.size === 0) {
throw new QrRequestError("Die Datei ist leer."); throw new QrRequestError("Die Datei ist leer.");
} }
@@ -126,6 +137,8 @@ export async function storeUpload(
throw new QrRequestError( throw new QrRequestError(
kind === "pdf" kind === "pdf"
? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch." ? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch."
: 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.", : "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
); );
} }
@@ -134,11 +147,12 @@ export async function storeUpload(
data: { data: {
filename: sanitizeFilename( filename: sanitizeFilename(
file.name, file.name,
kind === "pdf" ? "dokument.pdf" : "bild", kind === "pdf" ? "dokument.pdf" : kind === "favicon" ? "favicon" : "bild",
), ),
mimeType: erkannt, mimeType: erkannt,
size: bytes.byteLength, size: bytes.byteLength,
data: Buffer.from(bytes), data: Buffer.from(bytes),
purpose: uploadPurposeOf(kind),
}, },
select: { id: true, filename: true, mimeType: true, size: true }, select: { id: true, filename: true, mimeType: true, size: true },
}); });
@@ -188,7 +202,9 @@ const ORPHAN_HOURS = 24;
export async function cleanupOrphanedUploads(): Promise<number> { export async function cleanupOrphanedUploads(): Promise<number> {
const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000); const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000);
const { count } = await prisma.upload.deleteMany({ 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; return count;
} }