From 4046b726260384d913fb9104940d6c294a34bc81 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 16 Aug 2026 02:18:49 +0200 Subject: [PATCH] Erscheinungsbild im Admin-Bereich anpassbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 + docs/funktionen.md | 39 +- docs/roadmap.md | 17 +- .../migration.sql | 31 ++ prisma/schema.prisma | 31 ++ src/app/(app)/layout.tsx | 28 +- .../verwaltung/erscheinungsbild/page.tsx | 53 +++ src/app/(app)/verwaltung/page.tsx | 29 +- src/app/api/admin/branding/route.ts | 96 +++++ src/app/api/uploads/route.ts | 14 +- src/app/layout.tsx | 60 ++- src/components/branding-admin.tsx | 377 ++++++++++++++++++ src/lib/branding-colors.ts | 112 ++++++ src/lib/branding.ts | 143 +++++++ src/lib/uploads.shared.ts | 56 ++- src/lib/uploads.ts | 24 +- 16 files changed, 1068 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/20260816090000_erscheinungsbild/migration.sql create mode 100644 src/app/(app)/verwaltung/erscheinungsbild/page.tsx create mode 100644 src/app/api/admin/branding/route.ts create mode 100644 src/components/branding-admin.tsx create mode 100644 src/lib/branding-colors.ts create mode 100644 src/lib/branding.ts 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 */ + {branding.appName} + ) : ( + + + + )} {/* 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. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/verwaltung/page.tsx b/src/app/(app)/verwaltung/page.tsx index fff7635..f4ca672 100644 --- a/src/app/(app)/verwaltung/page.tsx +++ b/src/app/(app)/verwaltung/page.tsx @@ -5,6 +5,7 @@ import { Icon, type IconName } from "@/components/icons"; import { Notice } from "@/components/ui"; import { isAuthEnabled } from "@/lib/auth/actor"; import { requirePagePermission } from "@/lib/auth/guard"; +import { getBranding } from "@/lib/branding"; import { prisma } from "@/lib/db"; export const metadata: Metadata = { title: "Verwaltung" }; @@ -45,12 +46,21 @@ function Tile({ export default async function AdminPage() { await requirePagePermission("instance:manage"); - const [userCount, groupCount, codeCount, domainCount] = await Promise.all([ - prisma.user.count(), - prisma.group.count(), - prisma.qrCode.count(), - prisma.domain.count(), - ]); + const [userCount, groupCount, codeCount, domainCount, branding] = + await Promise.all([ + prisma.user.count(), + prisma.group.count(), + prisma.qrCode.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 (
@@ -110,6 +120,13 @@ export default async function AdminPage() { description="Festlegen, welche Typen auf der Startseite erscheinen." hint="15 Typen insgesamt" /> +
); diff --git a/src/app/api/admin/branding/route.ts b/src/app/api/admin/branding/route.ts new file mode 100644 index 0000000..3d67a89 --- /dev/null +++ b/src/app/api/admin/branding/route.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index e9fa175..ebd524c 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -9,6 +9,7 @@ import { isUploadKind, maxUploadBytes, storeUpload, + uploadPurposeOf, } from "@/lib/uploads"; export const dynamic = "force-dynamic"; @@ -28,10 +29,6 @@ export async function POST(request: NextRequest): Promise { 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; try { form = await request.formData(); @@ -44,6 +41,15 @@ export async function POST(request: NextRequest): Promise { 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"); if (!(file instanceof File)) { return jsonError("Es wurde keine Datei mitgeschickt."); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eecbed9..3621742 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,28 +1,66 @@ import type { Metadata } from "next"; +import { accentCss } from "@/lib/branding-colors"; +import { getBranding } from "@/lib/branding"; + import "./globals.css"; -export const metadata: Metadata = { - title: { - default: "QR-Code-Generator", - template: "%s · QR-Code-Generator", - }, - description: - "QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.", -}; +// Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf +// vorgerendert werden. +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const branding = await getBranding(); + + return { + title: { + default: branding.appName, + template: `%s · ${branding.appName}`, + }, + description: + "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 * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]` * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und * „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 ( - {children} + + {/* + Ü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. + */} +