Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell. Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl, damit die helle Fassung nicht kurz aufblitzt. Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt `bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`. Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die Gestaltung fest. Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der niedrigste bei 5,9:1.
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
import Link from "next/link";
|
|
|
|
import { LogoutButton } from "@/components/auth-forms";
|
|
import { Icon } from "@/components/icons";
|
|
import { ThemeToggle } from "@/components/theme-toggle";
|
|
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.
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const NAV_LINK =
|
|
"rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
|
|
|
|
/**
|
|
* Rahmen der eigentlichen Anwendung: Kopfzeile mit Navigation, Inhalt,
|
|
* Fußzeile. Die öffentlichen Landingpages liegen bewusst außerhalb dieser
|
|
* Gruppe und bekommen davon nichts ab.
|
|
*/
|
|
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">
|
|
<header className="border-b border-slate-200 bg-surface">
|
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
|
<Link
|
|
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 */
|
|
<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. 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">
|
|
<ThemeToggle />
|
|
<span
|
|
aria-hidden="true"
|
|
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
|
/>
|
|
{signedIn ? (
|
|
<>
|
|
<Link href="/" className={NAV_LINK}>
|
|
Erstellen
|
|
</Link>
|
|
<Link href="/meine-codes" className={NAV_LINK}>
|
|
Meine Codes
|
|
</Link>
|
|
{showAdmin ? (
|
|
<Link href="/verwaltung" className={NAV_LINK}>
|
|
Verwaltung
|
|
</Link>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
|
|
{isAuthEnabled() && actor.kind === "user" ? (
|
|
<>
|
|
<span
|
|
aria-hidden="true"
|
|
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
|
/>
|
|
<Link
|
|
href="/konto"
|
|
title={`Konto von ${actor.user.email}`}
|
|
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
|
>
|
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
|
|
{actorLabel(actor).slice(0, 2)}
|
|
</span>
|
|
<span className="hidden truncate sm:inline">
|
|
{actorLabel(actor)}
|
|
</span>
|
|
</Link>
|
|
<LogoutButton />
|
|
</>
|
|
) : null}
|
|
|
|
{isAuthEnabled() && actor.kind === "anonymous" ? (
|
|
<Link href="/anmelden" className={NAV_LINK}>
|
|
Anmelden
|
|
</Link>
|
|
) : null}
|
|
|
|
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
|
|
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
|
|
{!isAuthEnabled() ? (
|
|
<>
|
|
<span
|
|
aria-hidden="true"
|
|
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
|
/>
|
|
<Link
|
|
href="/konto"
|
|
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
|
|
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
|
>
|
|
Ohne Anmeldung
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1">{children}</main>
|
|
|
|
<footer className="border-t border-slate-200 bg-surface">
|
|
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
|
{branding.appName}
|
|
{branding.footerText ? ` · ${branding.footerText}` : null}
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|