feat: App-Hülle mit Header, Theme-Umschalter und Übersicht

Layout mit Inline-Skript, das data-theme setzt, bevor der erste Pixel
gemalt wird — sonst blitzt beim Laden das falsche Farbschema auf. Logo als
React-Komponente statt als Bilddatei: nimmt Theme-Farben an, spart einen
Netzwerkabruf und die Verläufe liegen einmalig im Root-Layout.

Im hellen Modus greifen eigene Tokens für Verlaufstext und Wortmarke; der
vollfarbige Signaturverlauf fällt auf warmem Weiß sonst unter WCAG AA.
This commit is contained in:
Florian Wilde
2026-09-01 18:46:47 +02:00
parent 01c0f9bc9f
commit b001e62695
11 changed files with 880 additions and 1 deletions
+120
View File
@@ -0,0 +1,120 @@
import Link from "next/link";
import { clsx } from "clsx";
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
import { ThemeToggle } from "@/components/ThemeToggle";
import { NavLink } from "@/components/NavLink";
import { UserMenu } from "@/components/UserMenu";
import type { SessionUser } from "@/lib/auth";
import pkg from "../../package.json";
const NAV = [
{ href: "/", label: "Übersicht" },
{ href: "/bibliothek", label: "Bibliothek" },
{ href: "/universen", label: "Universen" },
{ href: "/kalender", label: "Kalender" },
{ href: "/statistik", label: "Statistik" },
] as const;
/**
* Kopfzeile mit Logo, Hauptnavigation und Nutzermenü.
* Auf schmalen Geräten rutscht die Navigation in eine eigene, scrollbare Zeile.
*/
export function AppHeader({ user, instanceName }: { user: SessionUser; instanceName: string }) {
return (
<header className="sticky top-0 z-40 border-b border-[var(--fl-border)] bg-[color-mix(in_oklab,var(--fl-bg)_82%,transparent)] backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:px-6">
<Link href="/" className="rounded-lg" aria-label={`${instanceName} — zur Übersicht`}>
<FlimmerLogo />
</Link>
<nav aria-label="Hauptnavigation" className="ml-4 hidden items-center gap-1 md:flex">
{NAV.map((item) => (
<NavLink key={item.href} href={item.href}>
{item.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<Link href="/suche" className="fl-btn fl-btn-ghost px-2.5" aria-label="Titel suchen">
<SearchIcon />
<span className="sr-only lg:not-sr-only lg:text-sm">Suchen</span>
</Link>
<ThemeToggle />
<UserMenu user={user} />
</div>
</div>
<nav
aria-label="Hauptnavigation"
className="flex gap-1 overflow-x-auto px-3 pb-2 md:hidden [scrollbar-width:none]"
>
{NAV.map((item) => (
<NavLink key={item.href} href={item.href}>
{item.label}
</NavLink>
))}
</nav>
</header>
);
}
export function AppFooter({ className }: { className?: string }) {
return (
<footer
className={clsx(
"mx-auto mt-16 flex max-w-7xl flex-wrap items-center gap-x-4 gap-y-2 px-4 pb-10 text-sm text-[var(--fl-faint)] sm:px-6",
className,
)}
>
<span className="inline-flex items-center gap-1.5">
<span className="fl-gradient-text font-semibold">Flimmer</span>
<span>Version {pkg.version}</span>
</span>
<Link href="/einstellungen" className="hover:text-[var(--fl-muted)]">
Einstellungen
</Link>
<Link href="/papierkorb" className="hover:text-[var(--fl-muted)]">
Papierkorb
</Link>
<a
href="https://www.themoviedb.org/"
target="_blank"
rel="noreferrer noopener"
className="ml-auto hover:text-[var(--fl-muted)]"
>
Metadaten von TMDB
</a>
</footer>
);
}
/** Rahmen für alle angemeldeten Seiten. */
export function AppShell({
user,
instanceName,
children,
}: {
user: SessionUser;
instanceName: string;
children: React.ReactNode;
}) {
return (
<div className="flex min-h-dvh flex-col">
<AppHeader user={user} instanceName={instanceName} />
<main id="inhalt" className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
{children}
</main>
<AppFooter />
</div>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
/**
* Navigationslink, der den aktiven Bereich hervorhebt.
* „Aktiv" heißt: exakter Treffer bei „/", sonst Präfix — damit auch
* Unterseiten wie /bibliothek/serie-xy den Punkt markiert lassen.
*/
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={clsx(
"relative shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-[var(--fl-text)]"
: "text-[var(--fl-muted)] hover:text-[var(--fl-text)]",
)}
>
{children}
{active && (
<span
aria-hidden
className="absolute inset-x-2.5 -bottom-0.5 h-0.5 rounded-full"
style={{ backgroundImage: "var(--fl-gradient-ink)" }}
/>
)}
</Link>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
type Mode = "dark" | "light" | "system";
const STORAGE_KEY = "flimmer-theme";
function apply(mode: Mode) {
const resolved =
mode === "system"
? window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark"
: mode;
document.documentElement.setAttribute("data-theme", resolved);
}
/**
* Umschalter Dunkel / Hell / System.
*
* Der tatsächliche Wechsel passiert über das Attribut `data-theme` am
* <html>-Element — sämtliche Farben hängen an den CSS-Variablen, die darauf
* reagieren. Das Setzen beim Seitenaufbau übernimmt ein Inline-Skript im
* Layout, damit nichts aufblitzt.
*/
export function ThemeToggle({ className }: { className?: string }) {
const [mode, setMode] = useState<Mode>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Mode | null;
setMode(stored ?? "system");
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
localStorage.setItem(STORAGE_KEY, mode);
apply(mode);
if (mode !== "system") return;
const media = window.matchMedia("(prefers-color-scheme: light)");
const onChange = () => apply("system");
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [mode, mounted]);
const next: Record<Mode, Mode> = { dark: "light", light: "system", system: "dark" };
const label: Record<Mode, string> = {
dark: "Dunkel",
light: "Hell",
system: "System",
};
return (
<button
type="button"
onClick={() => setMode(next[mode])}
className={className ?? "fl-btn fl-btn-ghost px-2.5"}
title={`Farbschema: ${label[mode]} — klicken für ${label[next[mode]]}`}
aria-label={`Farbschema wechseln, aktuell ${label[mode]}`}
>
<ThemeIcon mode={mounted ? mode : "dark"} />
<span className="sr-only sm:not-sr-only sm:text-sm">{mounted ? label[mode] : ""}</span>
</button>
);
}
function ThemeIcon({ mode }: { mode: Mode }) {
if (mode === "light") {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="4.5" />
<path
d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8"
strokeLinecap="round"
/>
</svg>
);
}
if (mode === "system") {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="2.5" y="4" width="19" height="13" rx="2.5" />
<path d="M8 20.5h8M12 17.5v3" strokeLinecap="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M20 13.4A8.5 8.5 0 1 1 10.6 4a6.8 6.8 0 0 0 9.4 9.4Z" strokeLinejoin="round" />
</svg>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import type { SessionUser } from "@/lib/auth";
/**
* Nutzermenü mit Initialen-Avatar.
* Bewusst ohne Menü-Bibliothek: ein <details>-loses Popover mit Escape- und
* Außenklick-Behandlung reicht und bleibt tastaturbedienbar.
*/
export function UserMenu({ user }: { user: SessionUser }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointer = (event: MouseEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const initials = user.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={open}
className="fl-gradient-bg grid h-9 w-9 place-items-center rounded-full text-sm font-bold text-[var(--fl-on-accent)] shadow-[var(--fl-glow)] transition-shadow hover:shadow-[var(--fl-glow-strong)]"
>
{initials || "?"}
<span className="sr-only">Menü für {user.name}</span>
</button>
{open && (
<div
role="menu"
className="fl-card absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
style={{ backgroundColor: "var(--fl-surface-solid)" }}
>
<div className="border-b border-[var(--fl-border)] px-3 py-2">
<p className="truncate text-sm font-semibold">{user.name}</p>
<p className="truncate text-xs text-[var(--fl-faint)]">{user.email}</p>
</div>
<MenuLink href="/einstellungen" onClick={() => setOpen(false)}>
Einstellungen
</MenuLink>
<MenuLink href="/einstellungen/benachrichtigungen" onClick={() => setOpen(false)}>
Benachrichtigungen
</MenuLink>
<MenuLink href="/einstellungen/api" onClick={() => setOpen(false)}>
API-Schlüssel
</MenuLink>
<MenuLink href="/papierkorb" onClick={() => setOpen(false)}>
Papierkorb
</MenuLink>
{user.role === "ADMIN" && (
<MenuLink href="/verwaltung" onClick={() => setOpen(false)}>
Verwaltung
</MenuLink>
)}
<form action="/api/auth/abmelden" method="post" className="border-t border-[var(--fl-border)] pt-1.5">
<button
type="submit"
role="menuitem"
className="w-full rounded-lg px-3 py-2 text-left text-sm text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
>
Abmelden
</button>
</form>
</div>
)}
</div>
);
}
function MenuLink({
href,
onClick,
children,
}: {
href: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Link
href={href}
role="menuitem"
onClick={onClick}
className="block rounded-lg px-3 py-2 text-sm text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
>
{children}
</Link>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { clsx } from "clsx";
/**
* Die Marke als React-Komponenten.
*
* Inline statt <img src="/brand/…">: so kostet das Logo keinen zweiten
* Netzwerkabruf, lässt sich animieren und die monochrome Variante nimmt die
* Textfarbe des Kontexts an.
*
* Die Verläufe liegen einmalig in <BrandDefs /> (im Root-Layout gerendert) und
* werden von allen Logo-Instanzen referenziert — sonst gäbe es doppelte IDs.
*/
const GRADIENT = "url(#fl-brand-gradient)";
const HALO = "url(#fl-brand-halo)";
const BLOOM = "url(#fl-brand-bloom)";
/** Einmalig pro Dokument rendern (Root-Layout). */
export function BrandDefs() {
return (
<svg width="0" height="0" aria-hidden focusable="false" style={{ position: "absolute" }}>
<defs>
<linearGradient
id="fl-brand-gradient"
x1="20"
y1="24"
x2="108"
y2="102"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--fl-brand-1)" />
<stop offset="0.38" stopColor="var(--fl-brand-2)" />
<stop offset="1" stopColor="var(--fl-brand-3)" />
</linearGradient>
<radialGradient id="fl-brand-halo" cx="64" cy="58" r="52" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="var(--fl-brand-1)" stopOpacity="0.5" />
<stop offset="0.45" stopColor="var(--fl-brand-2)" stopOpacity="0.24" />
<stop offset="1" stopColor="var(--fl-brand-3)" stopOpacity="0" />
</radialGradient>
<filter id="fl-brand-bloom" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" />
</filter>
<linearGradient
id="fl-brand-word"
x1="0"
y1="10"
x2="418"
y2="108"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--fl-brand-1)" />
<stop offset="0.38" stopColor="var(--fl-brand-2)" />
<stop offset="1" stopColor="var(--fl-brand-3)" />
</linearGradient>
</defs>
</svg>
);
}
type MarkProps = {
className?: string;
/** Ohne Glühen und ohne Verlauf — nimmt die Textfarbe an (currentColor). */
mono?: boolean;
/** Verlauf, aber kein Glühen — für sehr kleine Darstellungen. */
flat?: boolean;
title?: string;
};
export function FlimmerMark({ className, mono = false, flat = false, title }: MarkProps) {
const paint = mono ? "currentColor" : GRADIENT;
return (
<svg
viewBox="0 0 128 128"
className={clsx("h-8 w-8 shrink-0", className)}
role={title ? "img" : "presentation"}
aria-label={title}
aria-hidden={title ? undefined : true}
>
{!mono && !flat && (
<>
<ellipse cx="64" cy="58" rx="50" ry="40" fill={HALO} filter={BLOOM} />
<rect
x="22"
y="26"
width="84"
height="64"
rx="18"
fill="none"
stroke={paint}
strokeWidth="8"
filter={BLOOM}
opacity="0.7"
/>
</>
)}
<rect x="22" y="26" width="84" height="64" rx="18" fill="none" stroke={paint} strokeWidth="7" />
<path
d="M55.5 45.5 L80 58 L55.5 70.5 Z"
fill={paint}
stroke={paint}
strokeWidth="6.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path
d="M64 90 V101 M49 105.5 H79"
stroke={paint}
strokeWidth="6.5"
strokeLinecap="round"
strokeOpacity="0.62"
/>
</svg>
);
}
/**
* Wortmarke als Pfad — unabhängig von installierten Schriften und damit
* überall identisch (Header, Login, E-Mail-Vorschau, Druck).
*/
export function FlimmerWordmark({
className,
mono = false,
}: {
className?: string;
mono?: boolean;
}) {
const paint = mono ? "currentColor" : "url(#fl-brand-word)";
return (
<svg
viewBox="0 0 432 118"
className={clsx("h-6 w-auto", className)}
role="presentation"
aria-hidden
>
<g
transform="translate(0 8)"
fill="none"
stroke={paint}
strokeWidth="15"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 100 V10 H62" />
<path d="M14 54 H52" />
<path d="M84 100 V6" />
<path d="M106 100 V46" />
<path d="M128 100 V64 Q128 46 146 46 Q164 46 164 64 V100" />
<path d="M164 64 Q164 46 182 46 Q200 46 200 64 V100" />
<path d="M218 100 V64 Q218 46 236 46 Q254 46 254 64 V100" />
<path d="M254 64 Q254 46 272 46 Q290 46 290 64 V100" />
<path d="M344.5 91.8 A29 19.5 0 1 1 366 73 H308" />
<path d="M392 100 V46" />
<path d="M392 64 Q392 46 414 46" />
</g>
<circle cx="106" cy="30" r="7.5" fill={paint} />
</svg>
);
}
/** Bildmarke plus Wortmarke — die Standard-Sperrform. */
export function FlimmerLogo({
className,
markClassName,
wordClassName,
mono = false,
}: {
className?: string;
markClassName?: string;
wordClassName?: string;
mono?: boolean;
}) {
return (
<span className={clsx("inline-flex items-center gap-2.5", className)}>
<FlimmerMark className={clsx("h-9 w-9", markClassName)} mono={mono} />
<FlimmerWordmark className={clsx("h-[1.35rem]", wordClassName)} mono={mono} />
<span className="sr-only">Flimmer</span>
</span>
);
}