mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -04:00
241 lines
7.7 KiB
TypeScript
241 lines
7.7 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import { IconCalendarEvent } from "@tabler/icons-react";
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
import { zodValidator } from "@tanstack/zod-adapter";
|
|
import { z } from "zod";
|
|
|
|
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
|
|
import { RouteError } from "@/components/route-error";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
|
import { orpc } from "@/lib/orpc/client";
|
|
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
|
|
|
const upcomingSearchSchema = z.object({
|
|
view: z.enum(["upcoming", "recent"]).optional().catch(undefined),
|
|
type: z.enum(["all", "movie", "tv"]).optional().catch(undefined),
|
|
status: z.enum(["all", "watching", "watchlist"]).optional().catch(undefined),
|
|
});
|
|
|
|
export const Route = createFileRoute("/_app/upcoming")({
|
|
validateSearch: zodValidator(upcomingSearchSchema),
|
|
staleTime: 30_000,
|
|
loader: async ({ context }) => {
|
|
await context.queryClient.ensureInfiniteQueryData(
|
|
orpc.library.upcoming.infiniteOptions({
|
|
input: (pageParam: string | undefined) => ({
|
|
days: 90,
|
|
limit: 20,
|
|
cursor: pageParam,
|
|
}),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
}),
|
|
);
|
|
},
|
|
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
|
pendingComponent: UpcomingSkeleton,
|
|
errorComponent: RouteError,
|
|
component: UpcomingPage,
|
|
});
|
|
|
|
function UpcomingSkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="mt-2 h-4 w-64" />
|
|
</div>
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<div key={i} className="flex items-center gap-3.5 py-2">
|
|
<Skeleton className="size-[52px] rounded-md" />
|
|
<div className="flex-1 space-y-1.5">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-3 w-28" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UpcomingPage() {
|
|
const { t } = useLingui();
|
|
const search = Route.useSearch();
|
|
const navigate = useNavigate({ from: Route.fullPath });
|
|
|
|
const view = search.view ?? "upcoming";
|
|
const isRecent = view === "recent";
|
|
const typeFilter = search.type ?? "all";
|
|
const statusFilter = search.status ?? "all";
|
|
|
|
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
|
|
useInfiniteQuery(
|
|
orpc.library.upcoming.infiniteOptions({
|
|
input: (pageParam: string | undefined) => ({
|
|
days: 90,
|
|
limit: 20,
|
|
cursor: pageParam,
|
|
direction: isRecent ? "recent" : undefined,
|
|
mediaType: !isRecent && typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
|
|
statusFilter:
|
|
!isRecent && statusFilter !== "all"
|
|
? [statusFilter as "watching" | "watchlist"]
|
|
: undefined,
|
|
}),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
}),
|
|
);
|
|
|
|
const sentinelRef = useInfiniteScroll({
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
isFetchNextPageError,
|
|
});
|
|
|
|
function setView(value: "upcoming" | "recent") {
|
|
void navigate({
|
|
search: (prev) => ({
|
|
...prev,
|
|
view: value === "upcoming" ? undefined : value,
|
|
}),
|
|
replace: true,
|
|
});
|
|
}
|
|
|
|
function setTypeFilter(value: string) {
|
|
void navigate({
|
|
search: (prev) => ({
|
|
...prev,
|
|
type: value === "all" ? undefined : (value as "movie" | "tv"),
|
|
}),
|
|
replace: true,
|
|
});
|
|
}
|
|
|
|
function setStatusFilter(value: string) {
|
|
void navigate({
|
|
search: (prev) => ({
|
|
...prev,
|
|
status: value === "all" ? undefined : (value as "watching" | "watchlist"),
|
|
}),
|
|
replace: true,
|
|
});
|
|
}
|
|
|
|
const filterToggles = (
|
|
<div className="space-y-3">
|
|
<ToggleGroup
|
|
value={[view]}
|
|
onValueChange={(values) => {
|
|
const next = values.find((v) => v !== view);
|
|
if (next === "upcoming" || next === "recent") setView(next);
|
|
}}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
<ToggleGroupItem value="upcoming">{t`Upcoming`}</ToggleGroupItem>
|
|
<ToggleGroupItem value="recent">{t`Recently aired`}</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
{!isRecent && (
|
|
<div className="flex flex-wrap gap-3">
|
|
<ToggleGroup
|
|
value={[typeFilter]}
|
|
onValueChange={(values) => {
|
|
const next = values.find((v) => v !== typeFilter) ?? "all";
|
|
setTypeFilter(next);
|
|
}}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
|
<ToggleGroupItem value="movie">{t`Movies`}</ToggleGroupItem>
|
|
<ToggleGroupItem value="tv">{t`TV Shows`}</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
<ToggleGroup
|
|
value={[statusFilter]}
|
|
onValueChange={(values) => {
|
|
const next = values.find((v) => v !== statusFilter) ?? "all";
|
|
setStatusFilter(next);
|
|
}}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
|
<ToggleGroupItem value="watching">{t`Watching`}</ToggleGroupItem>
|
|
<ToggleGroupItem value="watchlist">{t`Watchlist`}</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (isPending) return <UpcomingSkeleton />;
|
|
|
|
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
|
|
|
|
if (allItems.length === 0) {
|
|
return (
|
|
<div className="mx-auto max-w-2xl space-y-6">
|
|
<UpcomingHeader view={view} />
|
|
{filterToggles}
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<IconCalendarEvent className="text-muted-foreground/40 size-12" />
|
|
<p className="text-muted-foreground mt-4 text-sm">
|
|
{isRecent ? (
|
|
<Trans>You're all caught up on the last 90 days.</Trans>
|
|
) : (
|
|
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const buckets = groupByDateBucket(allItems, { past: isRecent });
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl space-y-6">
|
|
<UpcomingHeader view={view} />
|
|
{filterToggles}
|
|
{buckets.map((bucket) => (
|
|
<section key={bucket.key}>
|
|
<h2 className="font-display text-muted-foreground mb-2 text-sm font-medium tracking-wider uppercase">
|
|
{bucket.label}
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{bucket.items.map((item, i) => (
|
|
<UpcomingRow key={`${item.titleId}-${item.date}-${i}`} item={item} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
<div ref={sentinelRef} />
|
|
{isFetchingNextPage && <UpcomingSkeleton />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UpcomingHeader({ view }: { view: "upcoming" | "recent" }) {
|
|
const { t } = useLingui();
|
|
return (
|
|
<div>
|
|
<h1 className="font-display text-2xl tracking-tight">
|
|
{view === "recent" ? t`Recently aired` : t`Upcoming`}
|
|
</h1>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
{view === "recent" ? (
|
|
<Trans>Unwatched episodes from the last 90 days.</Trans>
|
|
) : (
|
|
<Trans>Episodes and movies coming up in the next 90 days.</Trans>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|