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:
+201
-55
@@ -2,82 +2,228 @@ 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 { 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
|
||||
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein.
|
||||
* 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}>
|
||||
<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>
|
||||
<h1 className="sr-only">Übersicht</h1>
|
||||
|
||||
<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
|
||||
<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>
|
||||
|
||||
{!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="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>
|
||||
|
||||
<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."
|
||||
/>
|
||||
<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 PlaceholderCard({ title, text }: { title: string; text: string }) {
|
||||
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user