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.
128 lines
5.0 KiB
TypeScript
128 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|