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: var(--fl-amber); /* als Textfarbe nutzbar: 11,3:1 */
|
||||||
--fl-accent-alt: var(--fl-magenta); /* als Textfarbe nutzbar: 6,0: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 */
|
--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%);
|
--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-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: 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;
|
--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: #8a4b00; /* als Textfarbe: 6,4:1 */
|
||||||
--fl-accent-alt: #b01262; /* 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 */
|
--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%);
|
--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-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: 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;
|
--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 {
|
@utility fl-gradient-text {
|
||||||
background-image: var(--fl-gradient);
|
background-image: var(--fl-gradient-ink);
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
color: transparent;
|
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