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