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:
+17
-1
@@ -41,7 +41,15 @@
|
||||
--fl-accent: var(--fl-amber); /* als Textfarbe nutzbar: 11,3:1 */
|
||||
--fl-accent-alt: var(--fl-magenta); /* als Textfarbe nutzbar: 6,0:1 */
|
||||
--fl-on-accent: #1a1024; /* Text auf dem Gradient: min. 5,5:1 */
|
||||
/* Markenstops fuer SVG-Verlaeufe (Logo). Als Variablen, damit das Logo im
|
||||
Light Mode dunkler wird und lesbar bleibt, ohne die Identitaet zu aendern. */
|
||||
--fl-brand-1: #ffc46b;
|
||||
--fl-brand-2: #ffb454;
|
||||
--fl-brand-3: #ff4d9d;
|
||||
|
||||
--fl-gradient: linear-gradient(115deg, var(--fl-amber-soft) 0%, var(--fl-amber) 38%, var(--fl-magenta) 100%);
|
||||
/* Fuer Text: im Dunkeln identisch, im Hellen deutlich abgedunkelt (AA). */
|
||||
--fl-gradient-ink: var(--fl-gradient);
|
||||
--fl-gradient-soft: linear-gradient(115deg, #ffb45426 0%, #ff4d9d26 100%);
|
||||
--fl-glow: 0 0 0 1px #ffb4541f, 0 8px 34px -10px #ff4d9d59, 0 2px 12px -4px #ffb45440;
|
||||
--fl-glow-strong: 0 0 0 1px #ffb4543d, 0 14px 48px -12px #ff4d9d80, 0 4px 18px -6px #ffb4546b;
|
||||
@@ -80,7 +88,15 @@
|
||||
--fl-accent: #8a4b00; /* als Textfarbe: 6,4:1 */
|
||||
--fl-accent-alt: #b01262; /* als Textfarbe: 6,4:1 */
|
||||
--fl-on-accent: #23122c; /* Text auf dem Gradient: min. 6,1:1 */
|
||||
/* Logo im Hellen: dieselbe Bernstein-zu-Magenta-Bewegung, nur tiefer
|
||||
angesetzt — sonst verschwindet die Wortmarke auf dem warmen Weiss. */
|
||||
--fl-brand-1: #c26a00;
|
||||
--fl-brand-2: #b35d00;
|
||||
--fl-brand-3: #c9186f;
|
||||
|
||||
--fl-gradient: linear-gradient(115deg, var(--fl-amber-soft) 0%, var(--fl-amber) 38%, var(--fl-magenta) 100%);
|
||||
/* Verlaufstext im Hellen: beide Enden erfuellen AA auf --fl-bg. */
|
||||
--fl-gradient-ink: linear-gradient(115deg, #8a4b00 0%, #a33a2a 45%, #b01262 100%);
|
||||
--fl-gradient-soft: linear-gradient(115deg, #ffb4542e 0%, #ff4d9d2e 100%);
|
||||
--fl-glow: 0 0 0 1px #d8a15c33, 0 8px 26px -12px #ff4d9d4d, 0 2px 10px -4px #ffb4544d;
|
||||
--fl-glow-strong: 0 0 0 1px #d8a15c59, 0 14px 40px -14px #ff4d9d66, 0 4px 16px -6px #ffb45473;
|
||||
@@ -181,7 +197,7 @@
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
@utility fl-gradient-text {
|
||||
background-image: var(--fl-gradient);
|
||||
background-image: var(--fl-gradient-ink);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Outfit } from "next/font/google";
|
||||
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
||||
import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende
|
||||
* Instanz fragt also niemals bei Google an.
|
||||
*/
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-outfit",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(env.appUrl),
|
||||
title: {
|
||||
default: "Flimmer",
|
||||
template: "%s · Flimmer",
|
||||
},
|
||||
description: "Serien und Filme tracken — selbst gehostet, ohne fremde Konten.",
|
||||
applicationName: "Flimmer",
|
||||
manifest: "/manifest.webmanifest",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ url: "/favicon.ico", sizes: "48x48" },
|
||||
],
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Flimmer",
|
||||
description: "Serien und Filme tracken — selbst gehostet, ohne fremde Konten.",
|
||||
images: ["/og-image.png"],
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0c0a14" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#faf7f5" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt data-theme, bevor der erste Pixel gemalt wird — sonst blitzt beim
|
||||
* Laden kurz das falsche Farbschema auf.
|
||||
*/
|
||||
const THEME_BOOTSTRAP = `(function(){try{
|
||||
var stored=localStorage.getItem("flimmer-theme");
|
||||
var mode=stored==="light"||stored==="dark"?stored:
|
||||
(stored==="system"||!stored)&&window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
|
||||
document.documentElement.setAttribute("data-theme",mode);
|
||||
}catch(e){document.documentElement.setAttribute("data-theme","dark");}})();`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de" data-theme="dark" className={outfit.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP }} />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<BrandDefs />
|
||||
<a
|
||||
href="#inhalt"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 fl-btn fl-btn-primary"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* PWA-Manifest. Wird von Next unter /manifest.webmanifest ausgeliefert.
|
||||
* Die Icons stammen aus scripts/generate-icons.ts.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Flimmer — Serien- und Film-Tracker",
|
||||
short_name: "Flimmer",
|
||||
description: "Serien und Filme tracken — selbst gehostet, ohne fremde Konten.",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
background_color: "#0c0a14",
|
||||
theme_color: "#0c0a14",
|
||||
lang: "de",
|
||||
dir: "ltr",
|
||||
categories: ["entertainment", "productivity"],
|
||||
icons: [
|
||||
{ src: "/icons/icon-64.png", sizes: "64x64", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-96.png", sizes: "96x96", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-128.png", sizes: "128x128", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-256.png", sizes: "256x256", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-384.png", sizes: "384x384", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
|
||||
/**
|
||||
* Übersichtsseite.
|
||||
*
|
||||
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten
|
||||
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<section className="fl-card relative overflow-hidden p-6 sm:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 -top-32 h-72 w-72 rounded-full opacity-30 blur-3xl"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<FlimmerMark className="h-12 w-12" />
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {user.name.split(" ")[0]}.
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-[var(--fl-muted)]">
|
||||
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier
|
||||
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade
|
||||
verfügbar geworden ist.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||
Titel suchen und hinzufügen
|
||||
</Link>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-ghost">
|
||||
Zur Bibliothek
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!env.tmdb.isConfigured && (
|
||||
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine
|
||||
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte
|
||||
die Instanz neu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<PlaceholderCard
|
||||
title="Weiterschauen"
|
||||
text="Deine laufenden Serien mit der jeweils nächsten Episode."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Demnächst"
|
||||
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Neu verfügbar"
|
||||
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderCard({ title, text }: { title: string; text: string }) {
|
||||
return (
|
||||
<div className="fl-card p-5">
|
||||
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Deutsche Oberflächentexte.
|
||||
*
|
||||
* Aufbau: flache Punkt-Keys, gruppiert nach Bereich. Eine weitere Sprache
|
||||
* braucht nur eine Datei mit demselben Schlüsselsatz und einen Eintrag in
|
||||
* `src/i18n/index.ts` — der Typ `Dictionary` erzwingt Vollständigkeit.
|
||||
*/
|
||||
export const de = {
|
||||
"app.name": "Flimmer",
|
||||
"app.tagline": "Deine Serien und Filme, warm beleuchtet.",
|
||||
|
||||
"nav.dashboard": "Übersicht",
|
||||
"nav.library": "Bibliothek",
|
||||
"nav.universes": "Universen",
|
||||
"nav.calendar": "Kalender",
|
||||
"nav.stats": "Statistik",
|
||||
"nav.search": "Suchen",
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.admin": "Verwaltung",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.login": "Anmelden",
|
||||
"nav.register": "Registrieren",
|
||||
"nav.trash": "Papierkorb",
|
||||
|
||||
"theme.toggle": "Farbschema wechseln",
|
||||
"theme.dark": "Dunkel",
|
||||
"theme.light": "Hell",
|
||||
"theme.system": "System",
|
||||
|
||||
"auth.login.title": "Willkommen zurück",
|
||||
"auth.login.subtitle": "Melde dich an, um weiterzuschauen.",
|
||||
"auth.register.title": "Konto anlegen",
|
||||
"auth.register.subtitleFirst":
|
||||
"Du bist die erste Person hier — dieses Konto wird automatisch Administrator.",
|
||||
"auth.register.subtitle": "Lege dir eine eigene Bibliothek an.",
|
||||
"auth.email": "E-Mail-Adresse",
|
||||
"auth.password": "Passwort",
|
||||
"auth.passwordRepeat": "Passwort wiederholen",
|
||||
"auth.name": "Anzeigename",
|
||||
"auth.submitLogin": "Anmelden",
|
||||
"auth.submitRegister": "Konto anlegen",
|
||||
"auth.toRegister": "Noch kein Konto? Jetzt registrieren",
|
||||
"auth.toLogin": "Schon ein Konto? Zur Anmeldung",
|
||||
"auth.registrationClosed":
|
||||
"Die Registrierung ist auf dieser Instanz deaktiviert. Wende dich an die Administration.",
|
||||
"auth.error.invalid": "E-Mail-Adresse oder Passwort stimmt nicht.",
|
||||
"auth.error.inactive": "Dieses Konto ist deaktiviert.",
|
||||
"auth.error.emailTaken": "Für diese E-Mail-Adresse gibt es bereits ein Konto.",
|
||||
"auth.error.passwordMismatch": "Die beiden Passwörter stimmen nicht überein.",
|
||||
"auth.error.generic": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
|
||||
|
||||
"status.WATCHING": "Schaue gerade",
|
||||
"status.PAUSED": "Pausiert",
|
||||
"status.COMPLETED": "Abgeschlossen",
|
||||
"status.DROPPED": "Abgebrochen",
|
||||
"status.WATCHLIST": "Watchlist",
|
||||
|
||||
"type.SERIES": "Serie",
|
||||
"type.MOVIE": "Film",
|
||||
|
||||
"common.save": "Speichern",
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.delete": "Löschen",
|
||||
"common.undo": "Rückgängig",
|
||||
"common.loading": "Einen Moment …",
|
||||
"common.empty": "Hier ist noch nichts.",
|
||||
"common.error": "Da ist etwas schiefgelaufen.",
|
||||
"common.back": "Zurück",
|
||||
"common.more": "Mehr",
|
||||
|
||||
"footer.version": "Version",
|
||||
"footer.docs": "Anleitung",
|
||||
} as const;
|
||||
|
||||
export type Dictionary = typeof de;
|
||||
export type MessageKey = keyof Dictionary;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { de, type Dictionary, type MessageKey } from "./de";
|
||||
|
||||
/**
|
||||
* Minimaler Übersetzungshelfer.
|
||||
*
|
||||
* Bewusst ohne Bibliothek: Flimmer braucht kein Pluralisierungs- oder
|
||||
* Kontextsystem, sondern nur nachschlagbare Zeichenketten mit Platzhaltern.
|
||||
* Weitere Sprachen kommen als zusätzliche Einträge in `dictionaries` dazu.
|
||||
*/
|
||||
const dictionaries = { de } satisfies Record<string, Dictionary>;
|
||||
|
||||
export type Locale = keyof typeof dictionaries;
|
||||
export const DEFAULT_LOCALE: Locale = "de";
|
||||
export const AVAILABLE_LOCALES = Object.keys(dictionaries) as Locale[];
|
||||
|
||||
export function getDictionary(locale: string = DEFAULT_LOCALE): Dictionary {
|
||||
return dictionaries[locale as Locale] ?? dictionaries[DEFAULT_LOCALE];
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzt einen Schlüssel. Platzhalter werden als {name} geschrieben:
|
||||
* t("stats.hours", { hours: 42 })
|
||||
*/
|
||||
export function translate(
|
||||
locale: string,
|
||||
key: MessageKey,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const raw: string = getDictionary(locale)[key] ?? key;
|
||||
if (!vars) return raw;
|
||||
return raw.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in vars ? String(vars[name]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
/** Gebundene Variante für eine feste Sprache. */
|
||||
export function createTranslator(locale: string) {
|
||||
return (key: MessageKey, vars?: Record<string, string | number>) =>
|
||||
translate(locale, key, vars);
|
||||
}
|
||||
|
||||
export const t = createTranslator(DEFAULT_LOCALE);
|
||||
export type { MessageKey, Dictionary };
|
||||
Reference in New Issue
Block a user