diff --git a/README.md b/README.md
index 098ccee..56de034 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
- **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
von selbst; alles Nötige steht in einer `.env`.
diff --git a/docs/funktionen.md b/docs/funktionen.md
index 9b92f7e..f0f9af9 100644
--- a/docs/funktionen.md
+++ b/docs/funktionen.md
@@ -19,6 +19,7 @@ so entschieden ist. Wie man es bedient, steht in der
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Single Sign-on](#single-sign-on)
- [Mehrere Domains](#mehrere-domains)
+- [Erscheinungsbild](#erscheinungsbild)
- [Verwaltung](#verwaltung)
- [Betrieb](#betrieb)
- [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
| Bereich | Was dort geht |
| --- | --- |
+| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **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 |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `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 /s/{slug}` | öffentliche Landingpage |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 176981d..b7515ce 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -62,6 +62,12 @@ Damit sind **alle 15 vorgesehenen Typen** gebaut.
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
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)
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
die Auslieferungsroute — aber auch die Sicherungsstrategie.
-**Eigene Gestaltung der Landingpages** · groß
-Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma
-aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es
-braucht Grenzen, damit nichts Unlesbares entsteht.
+**Erscheinungsbild je Gruppe** · groß
+Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
+Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
+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ß
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
diff --git a/prisma/migrations/20260816090000_erscheinungsbild/migration.sql b/prisma/migrations/20260816090000_erscheinungsbild/migration.sql
new file mode 100644
index 0000000..a82c83d
--- /dev/null
+++ b/prisma/migrations/20260816090000_erscheinungsbild/migration.sql
@@ -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);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9fb8253..58ae0f5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -219,14 +219,45 @@ model Upload {
data Bytes
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 aber noch nicht gespeichert wurde; solche Reste räumt
/// `src/lib/uploads.ts` nach einer Weile weg.
qrCodeId String?
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
+ brandingLogo Branding? @relation("BrandingLogo")
+ brandingFavicon Branding? @relation("BrandingFavicon")
+
@@index([qrCodeId])
@@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
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 6f7c800..876b91a 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -4,6 +4,7 @@ import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy";
+import { getBranding } from "@/lib/branding";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden.
@@ -21,6 +22,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
const actor = await getActor();
const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage");
+ const branding = await getBranding();
return (
@@ -30,12 +32,25 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
href="/"
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 */
+
+ ) : (
+
+
+
+ )}
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
- Navigation nicht mehr in eine Zeile. */}
- QR-Code-Generator
+ Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
+ dieses schon den Namen. */}
+ {branding.logoUploadId ? null : (
+ {branding.appName}
+ )}
diff --git a/src/app/(app)/verwaltung/erscheinungsbild/page.tsx b/src/app/(app)/verwaltung/erscheinungsbild/page.tsx
new file mode 100644
index 0000000..a5f0754
--- /dev/null
+++ b/src/app/(app)/verwaltung/erscheinungsbild/page.tsx
@@ -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 (
+
+
+
+ Zur Verwaltung
+
+
+
+
+ Erscheinungsbild
+
+
+ Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen
+ gelten für die ganze Installation.
+
+
+ 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.
+