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