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.
230 lines
9.2 KiB
TypeScript
230 lines
9.2 KiB
TypeScript
import Link from "next/link";
|
|
import { requireUser } from "@/lib/auth";
|
|
import { getSettings } from "@/lib/settings";
|
|
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 { Poster } from "@/components/Poster";
|
|
import { ProgressBar } from "@/components/ProgressBar";
|
|
import { QuickWatch } from "@/components/library/QuickWatch";
|
|
|
|
/**
|
|
* Übersicht: der Einstieg für „was schaue ich als Nächstes".
|
|
*
|
|
* Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
|
|
* Watchlist verfügbar geworden ist.
|
|
*/
|
|
export default async function DashboardPage() {
|
|
const user = await requireUser();
|
|
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 (
|
|
<AppShell user={user} instanceName={settings.instanceName}>
|
|
<h1 className="sr-only">Übersicht</h1>
|
|
|
|
<section aria-labelledby="weiterschauen">
|
|
<div className="mb-3 flex items-baseline justify-between gap-3">
|
|
<h2 id="weiterschauen" className="text-lg font-semibold">
|
|
Weiterschauen
|
|
</h2>
|
|
<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>
|
|
</div>
|
|
|
|
<div className="fl-card p-4">
|
|
{upcoming.length === 0 ? (
|
|
<p className="text-sm text-[var(--fl-muted)]">
|
|
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
|
|
</p>
|
|
) : (
|
|
<ol className="space-y-2.5">
|
|
{upcoming.slice(0, 6).map((episode) => (
|
|
<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>
|
|
|
|
<section aria-labelledby="verfuegbar">
|
|
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
|
|
Neu verfügbar
|
|
</h2>
|
|
|
|
<div className="fl-card p-4">
|
|
{available.length === 0 ? (
|
|
<p className="text-sm text-[var(--fl-muted)]">
|
|
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
|
|
einem Dienst in {user.region} auftaucht, steht er hier.
|
|
</p>
|
|
) : (
|
|
<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>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
|
|
return (
|
|
<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">
|
|
<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>
|
|
);
|
|
}
|