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:
@@ -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 (
|
||||
<div className="flex flex-1 flex-col">
|
||||
@@ -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"
|
||||
>
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
{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">
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
|
||||
Navigation nicht mehr in eine Zeile. */}
|
||||
<span className="hidden sm:inline">QR-Code-Generator</span>
|
||||
Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
|
||||
dieses schon den Namen. */}
|
||||
{branding.logoUploadId ? null : (
|
||||
<span className="hidden sm:inline">{branding.appName}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</footer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
@@ -110,6 +120,13 @@ export default async function AdminPage() {
|
||||
description="Festlegen, welche Typen auf der Startseite erscheinen."
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
|
||||
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<Response> {
|
||||
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.");
|
||||
|
||||
+49
-11
@@ -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<Metadata> {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user