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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user