feat: Seiten für Übersicht, Kalender und Statistik

Der Kalender ist als Tabelle ausgezeichnet — ein Kalender ist eine Tabelle,
und mit Screenreader wird daraus dann „Woche 3, Donnerstag, 24." statt einer
ununterscheidbaren Kachelwüste.
This commit is contained in:
Florian Wilde
2026-09-01 19:54:16 +02:00
parent 0e51df7bad
commit 8c872b240a
5 changed files with 771 additions and 55 deletions
+127
View File
@@ -0,0 +1,127 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getUpcomingEpisodes } from "@/lib/stats";
import { AppShell } from "@/components/AppShell";
import { CalendarMonth } from "@/components/CalendarMonth";
import { episodeCode, formatDateLong } from "@/lib/labels";
export const metadata: Metadata = { title: "Kalender" };
/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */
function parseMonth(value: string | undefined): Date {
const match = value?.match(/^(\d{4})-(\d{2})$/);
if (match) {
const year = Number(match[1]);
const month = Number(match[2]) - 1;
if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) {
return new Date(year, month, 1);
}
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
}
function monthParam(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
export default async function CalendarPage({
searchParams,
}: {
searchParams: Promise<{ monat?: string }>;
}) {
const user = await requireUser("/kalender");
const settings = await getSettings();
const params = await searchParams;
const month = parseMonth(params.monat);
const from = new Date(month.getFullYear(), month.getMonth(), 1);
// Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch.
const gridFrom = new Date(from.getTime() - 7 * 86_400_000);
const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7);
const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo);
const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth());
const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1);
const next = new Date(month.getFullYear(), month.getMonth() + 1, 1);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ausstrahlungstermine der Serien in deiner Bibliothek.
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
<Link
href={`/kalender?monat=${monthParam(previous)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
←
</Link>
<span className="min-w-40 text-center font-medium">
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</span>
<Link
href={`/kalender?monat=${monthParam(next)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
→
</Link>
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Heute
</Link>
</nav>
</header>
<div className="fl-card overflow-x-auto p-3 sm:p-4">
<div className="min-w-[44rem]">
<CalendarMonth month={month} episodes={episodes} />
</div>
</div>
<section className="mt-6" aria-labelledby="liste">
<h2 id="liste" className="mb-3 text-lg font-semibold">
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
</h2>
{inMonth.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
In diesem Monat läuft nichts aus deiner Bibliothek.
</div>
) : (
<ol className="fl-card divide-y divide-[var(--fl-border)]">
{inMonth.map((episode) => (
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
{formatDateLong(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
</Link>
{episode.runtime && (
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
)}
</li>
))}
</ol>
)}
</section>
</AppShell>
);
}
+201 -55
View File
@@ -2,82 +2,228 @@ import Link from "next/link";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import {
getContinueWatching,
getNewlyAvailable,
getUpcomingEpisodes,
} from "@/lib/stats";
import { prisma } from "@/lib/db";
import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { FlimmerMark } from "@/components/brand/FlimmerLogo"; import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { QuickWatch } from "@/components/library/QuickWatch";
/** /**
* Übersichtsseite. * Übersicht: der Einstieg für „was schaue ich als Nächstes".
* *
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten * Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein. * Watchlist verfügbar geworden ist.
*/ */
export default async function DashboardPage() { export default async function DashboardPage() {
const user = await requireUser(); const user = await requireUser();
const settings = await getSettings(); const settings = await getSettings();
const now = new Date();
const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000);
const [continueItems, upcoming, available, libraryCount] = await Promise.all([
getContinueWatching(user.id, 8),
getUpcomingEpisodes(user.id, now, inFourWeeks),
getNewlyAvailable(user.id, user.region),
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
]);
if (libraryCount === 0) {
return (
<AppShell user={user} instanceName={settings.instanceName}>
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
</AppShell>
);
}
return ( return (
<AppShell user={user} instanceName={settings.instanceName}> <AppShell user={user} instanceName={settings.instanceName}>
<section className="fl-card relative overflow-hidden p-6 sm:p-10"> <h1 className="sr-only">Übersicht</h1>
<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"> <section aria-labelledby="weiterschauen">
<Link href="/suche" className="fl-btn fl-btn-primary"> <div className="mb-3 flex items-baseline justify-between gap-3">
Titel suchen und hinzufügen <h2 id="weiterschauen" className="text-lg font-semibold">
</Link> Weiterschauen
<Link href="/bibliothek" className="fl-btn fl-btn-ghost"> </h2>
Zur Bibliothek <Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Alle laufenden
</Link>
</div>
{continueItems.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
</div>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{continueItems.map((item) => (
<li key={item.entryId} className="fl-card flex gap-3 p-3">
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
<Poster path={item.posterPath} alt={item.title} size="w185" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
{item.title}
</Link>
{item.next && (
<p className="mt-0.5 truncate text-sm">
<span className="fl-gradient-text font-mono font-semibold">
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
</span>{" "}
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
</p>
)}
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{item.watched} von {item.total} Episoden
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
</div>
<div className="mt-2.5">
{item.next && isUpcoming(item.next.airDate) ? (
<p className="text-xs text-[var(--fl-faint)]">
Nächste Episode erst am {formatDate(item.next.airDate)}
</p>
) : (
<QuickWatch entryId={item.entryId} />
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<section aria-labelledby="demnaechst">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="demnaechst" className="text-lg font-semibold">
Demnächst
</h2>
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Zum Kalender
</Link> </Link>
</div> </div>
{!env.tmdb.isConfigured && ( <div className="fl-card p-4">
<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)]"> {upcoming.length === 0 ? (
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine <p className="text-sm text-[var(--fl-muted)]">
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
die Instanz neu. </p>
</p> ) : (
)} <ol className="space-y-2.5">
</div> {upcoming.slice(0, 6).map((episode) => (
</section> <li key={episode.episodeId} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
</Link>
</li>
))}
</ol>
)}
</div>
</section>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <section aria-labelledby="verfuegbar">
<PlaceholderCard <h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
title="Weiterschauen" Neu verfügbar
text="Deine laufenden Serien mit der jeweils nächsten Episode." </h2>
/>
<PlaceholderCard <div className="fl-card p-4">
title="Demnächst" {available.length === 0 ? (
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden." <p className="text-sm text-[var(--fl-muted)]">
/> Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
<PlaceholderCard einem Dienst in {user.region} auftaucht, steht er hier.
title="Neu verfügbar" </p>
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind." ) : (
/> <ul className="space-y-3">
{available.slice(0, 5).map((item) => (
<li key={item.titleId} className="flex items-center gap-3">
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/titel/${item.titleId}`}
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<p className="truncate text-xs text-[var(--fl-faint)]">
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
{formatRelative(item.since)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
</section>
</div> </div>
</AppShell> </AppShell>
); );
} }
function PlaceholderCard({ title, text }: { title: string; text: string }) { function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
return ( return (
<div className="fl-card p-5"> <section className="fl-card relative overflow-hidden p-6 sm:p-10">
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase"> <div
{title} aria-hidden
</h2> className="pointer-events-none absolute -right-24 -top-32 h-72 w-72 rounded-full opacity-30 blur-3xl"
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p> style={{ backgroundImage: "var(--fl-gradient)" }}
</div> />
<div className="relative">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
Hallo {name.split(" ")[0]}.
</h1>
<p className="mt-2 max-w-xl text-[var(--fl-muted)]">
Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
Watchlist 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
</Link>
<Link href="/titel/neu" className="fl-btn fl-btn-ghost">
Titel selbst anlegen
</Link>
</div>
{!tmdbReady && (
<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>
); );
} }
+267
View File
@@ -0,0 +1,267 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import {
getActiveYears,
getGenreDistribution,
getMonthlyActivity,
getMostWatched,
getProviderDistribution,
getTopRated,
getTotals,
getYearReview,
} from "@/lib/stats";
import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
export const metadata: Metadata = { title: "Statistik" };
export default async function StatsPage({
searchParams,
}: {
searchParams: Promise<{ jahr?: string }>;
}) {
const user = await requireUser("/statistik");
const settings = await getSettings();
const params = await searchParams;
const years = await getActiveYears(user.id);
const requested = Number.parseInt(params.jahr ?? "", 10);
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
getTotals(user.id),
getMonthlyActivity(user.id, 12),
getProviderDistribution(user.id),
getGenreDistribution(user.id),
getTopRated(user.id, 5),
getMostWatched(user.id, 5),
getYearReview(user.id, year),
]);
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
</header>
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
Schauzeit, Verteilungen und der Jahresrückblick.
</p>
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
Zur Bibliothek
</Link>
</div>
</AppShell>
);
}
const days = totals.minutes / (60 * 24);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
</p>
</header>
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
</p>
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
<Stat
label="In der Bibliothek"
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
/>
</dl>
</section>
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
Die letzten zwölf Monate
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Abgehakte Episoden und Filme je Monat.
</p>
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
</section>
<div className="mb-6 grid gap-6 lg:grid-cols-2">
<section className="fl-card p-5" aria-labelledby="dienste">
<h2 id="dienste" className="mb-1 text-lg font-semibold">
Nach Streamingdienst
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
</p>
<DistributionBars
rows={providers.slice(0, 8)}
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
useBrandColors
/>
</section>
<section className="fl-card p-5" aria-labelledby="genres">
<h2 id="genres" className="mb-1 text-lg font-semibold">
Nach Genre
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Titel mit mehreren Genres zählen in jedem mit.
</p>
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
</section>
</div>
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 id="rueckblick" className="text-lg font-semibold">
{review.year} in Zahlen
</h2>
{years.length > 1 && (
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
{years.slice(0, 6).map((entry) => (
<Link
key={entry}
href={`/statistik?jahr=${entry}`}
aria-current={entry === review.year ? "true" : undefined}
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
style={
entry === review.year
? {
backgroundImage: "var(--fl-gradient)",
color: "var(--fl-on-accent)",
borderColor: "transparent",
}
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{entry}
</Link>
))}
</div>
)}
</div>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
</dl>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
{review.busiestMonth && (
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
<span className="font-medium">{review.busiestMonth.label}</span>
<span className="text-[var(--fl-muted)]">
{" "}
mit {formatMinutes(review.busiestMonth.minutes)}
</span>
</p>
)}
{review.topTitle && (
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
{review.topTitle.title}
</Link>
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
</p>
)}
</div>
</section>
<div className="grid gap-6 lg:grid-cols-2">
<TitleList
id="meistzeit"
heading="Wo die meiste Zeit hinging"
items={mostWatched}
renderMeta={(item) => formatMinutes(item.minutes)}
emptyText="Noch nichts gesehen."
/>
<TitleList
id="bestbewertet"
heading="Deine besten Bewertungen"
items={topRated}
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
emptyText="Noch nichts bewertet."
/>
</div>
</AppShell>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
</div>
);
}
function TitleList({
id,
heading,
items,
renderMeta,
emptyText,
}: {
id: string;
heading: string;
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
renderMeta: (item: { rating: number | null; minutes: number }) => string;
emptyText: string;
}) {
return (
<section className="fl-card p-5" aria-labelledby={id}>
<h2 id={id} className="mb-4 text-lg font-semibold">
{heading}
</h2>
{items.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
) : (
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.titleId} className="flex items-center gap-3">
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
{index + 1}
</span>
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<Link
href={`/titel/${item.titleId}`}
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
{renderMeta(item)}
</span>
</li>
))}
</ol>
)}
</section>
);
}
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { clsx } from "clsx";
import { episodeCode } from "@/lib/labels";
import type { UpcomingEpisode } from "@/lib/stats";
/**
* Monatsraster mit den Ausstrahlungsterminen.
*
* Bewusst als Tabelle ausgezeichnet: ein Kalender *ist* eine Tabelle, und mit
* Screenreader wird daraus dann „Woche 3, Donnerstag, 14." statt einer
* ununterscheidbaren Kachelwüste.
*/
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function startOfGrid(month: Date): Date {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
// Montag als Wochenanfang: Sonntag (0) wird zu 6.
const weekday = (first.getDay() + 6) % 7;
return new Date(first.getFullYear(), first.getMonth(), 1 - weekday);
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function CalendarMonth({
month,
episodes,
}: {
month: Date;
episodes: UpcomingEpisode[];
}) {
const today = new Date();
const start = startOfGrid(month);
// Sechs Wochen zeigen immer den ganzen Monat, egal auf welchen Tag er fällt.
const days = Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
const byDay = new Map<string, UpcomingEpisode[]>();
for (const episode of episodes) {
const key = episode.airDate.toDateString();
const list = byDay.get(key);
if (list) list.push(episode);
else byDay.set(key, [episode]);
}
const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7));
return (
<table className="w-full table-fixed border-collapse">
<caption className="sr-only">
Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</caption>
<thead>
<tr>
{WEEKDAYS.map((day) => (
<th
key={day}
scope="col"
className="pb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]"
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, weekIndex) => (
<tr key={weekIndex}>
{week.map((date) => {
const inMonth = date.getMonth() === month.getMonth();
const isToday = isSameDay(date, today);
const list = byDay.get(date.toDateString()) ?? [];
return (
<td
key={date.toISOString()}
className={clsx(
"h-24 border border-[var(--fl-border)] p-1 align-top sm:h-28",
!inMonth && "opacity-40",
)}
style={
list.length > 0
? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" }
: undefined
}
>
<span
className={clsx(
"mb-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs tabular-nums",
isToday
? "font-semibold text-[var(--fl-on-accent)]"
: "text-[var(--fl-faint)]",
)}
style={isToday ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{date.getDate()}
</span>
<ul className="space-y-0.5">
{list.slice(0, 3).map((episode) => (
<li key={episode.episodeId}>
<Link
href={`/titel/${episode.titleId}`}
title={`${episode.title} — ${episodeCode(episode.seasonNumber, episode.episodeNumber)} ${episode.name}`}
className="block truncate rounded px-1 py-0.5 text-[0.7rem] leading-tight text-[var(--fl-text)] transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="font-mono text-[var(--fl-accent)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>{" "}
{episode.title}
</Link>
</li>
))}
{list.length > 3 && (
<li className="px-1 text-[0.7rem] text-[var(--fl-faint)]">
+ {list.length - 3} weitere
</li>
)}
</ul>
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { undoAction, watchNextAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* „+1 Episode" für die Übersicht — derselbe Griff wie auf der Detailseite,
* nur ohne Kontext drumherum.
*/
export function QuickWatch({ entryId }: { entryId: string }) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
+1 Episode
</button>
);
}