mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
feat: add upcoming episodes and movies timeline (#17)
This commit is contained in:
@@ -1,19 +1,30 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function FeedSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
seeAllLink,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
seeAllLink?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden={true}>{icon}</span>
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">{title}</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden={true}>{icon}</span>
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">{title}</h2>
|
||||
</div>
|
||||
{seeAllLink && (
|
||||
<Link to={seeAllLink} className="text-primary text-sm hover:underline">
|
||||
<Trans>See all</Trans>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||
import type { UpcomingItem } from "@sofa/api/schemas";
|
||||
|
||||
const statusColorClass = {
|
||||
in_watchlist: "bg-status-watchlist",
|
||||
watching: "bg-status-watching",
|
||||
caught_up: "bg-status-completed",
|
||||
completed: "bg-status-completed",
|
||||
} as const;
|
||||
|
||||
const statusHaloClass = statusColorClass;
|
||||
|
||||
function formatShortDate(dateStr: string): string {
|
||||
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function UpcomingRow({ item }: { item: UpcomingItem }) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const statusLabels = {
|
||||
in_watchlist: t`On Watchlist`,
|
||||
watching: t`Watching`,
|
||||
caught_up: t`Caught Up`,
|
||||
completed: t`Completed`,
|
||||
} as const;
|
||||
|
||||
let subtitle: string;
|
||||
if (item.titleType === "movie") {
|
||||
subtitle = formatShortDate(item.date);
|
||||
} else if (item.episodeCount > 1 && item.seasonNumber != null) {
|
||||
const seasonNum = item.seasonNumber;
|
||||
const epCount = item.episodeCount;
|
||||
subtitle = t`S${seasonNum} \u00b7 ${plural(epCount, { one: "# episode", other: "# episodes" })}`;
|
||||
} else {
|
||||
const episodeLabel =
|
||||
item.seasonNumber != null && item.episodeNumber != null
|
||||
? `S${item.seasonNumber}E${item.episodeNumber}`
|
||||
: null;
|
||||
subtitle = [episodeLabel, item.episodeName].filter(Boolean).join(" \u00b7 ");
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/titles/$id"
|
||||
params={{ id: item.titleId }}
|
||||
className="group bg-card/40 hover:bg-card/60 hover:shadow-primary/5 hover:ring-primary/25 flex items-center gap-4 rounded-xl px-3 py-3 ring-1 ring-white/[0.06] transition-[background,box-shadow,ring-color] duration-200 hover:shadow-lg"
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative h-[66px] w-11 shrink-0 overflow-hidden rounded-lg ring-1 ring-white/[0.06]">
|
||||
{item.posterPath ? (
|
||||
<img
|
||||
src={item.posterPath}
|
||||
alt=""
|
||||
className="size-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
|
||||
loading="lazy"
|
||||
{...(item.posterThumbHash
|
||||
? {
|
||||
style: {
|
||||
background: `url(${thumbHashToUrl(item.posterThumbHash)}) center/cover`,
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex size-full items-center justify-center">
|
||||
<IconMovie className="text-muted-foreground size-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="relative flex size-2 shrink-0">
|
||||
<span
|
||||
className={`absolute inline-flex size-full rounded-full opacity-40 motion-safe:animate-pulse ${statusHaloClass[item.userStatus]}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex size-2 rounded-full ${statusColorClass[item.userStatus]}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{statusLabels[item.userStatus]}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="truncate text-sm font-medium">{item.titleName}</span>
|
||||
{item.isNewSeason && (
|
||||
<span className="bg-primary/15 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
|
||||
{t`New Season`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs">
|
||||
{item.titleType === "movie" && <IconMovie className="size-3 shrink-0" />}
|
||||
<span className="truncate">{subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: date + provider logo */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="text-muted-foreground text-xs">{formatShortDate(item.date)}</span>
|
||||
{item.streamingProvider && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="shrink-0">
|
||||
{item.streamingProvider.logoPath ? (
|
||||
<img
|
||||
src={item.streamingProvider.logoPath}
|
||||
alt={item.streamingProvider.providerName}
|
||||
className="size-7 rounded-lg ring-1 ring-white/[0.06]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-[10px]">
|
||||
{item.streamingProvider.providerName}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{item.streamingProvider.providerName}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconCalendarEvent } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
import { FeedSection } from "./feed-section";
|
||||
import { UpcomingRow } from "./upcoming-item";
|
||||
|
||||
export function UpcomingSection() {
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
if (isPending) return null;
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FeedSection
|
||||
title={t`Upcoming`}
|
||||
icon={<IconCalendarEvent className="text-primary size-5" />}
|
||||
seeAllLink="/upcoming"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<UpcomingRow key={`${item.titleId}-${item.date}-${i}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</FeedSection>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconCompass, IconHome, IconLogout, IconSearch, IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCalendarEvent,
|
||||
IconCompass,
|
||||
IconHome,
|
||||
IconLogout,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
@@ -67,7 +74,6 @@ function useActiveIndicator<T>(
|
||||
const instantRef = useRef(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
instantRef.current = false;
|
||||
const update = () => {
|
||||
if (activeIndex === -1) {
|
||||
setValue(null);
|
||||
@@ -82,6 +88,8 @@ function useActiveIndicator<T>(
|
||||
}
|
||||
};
|
||||
update();
|
||||
// After the initial measurement, allow subsequent changes to animate
|
||||
instantRef.current = false;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
@@ -114,6 +122,7 @@ export function NavBar({
|
||||
const navLinks = [
|
||||
{ href: "/dashboard", label: t`Home` },
|
||||
{ href: "/explore", label: t`Explore` },
|
||||
{ href: "/upcoming", label: t`Upcoming` },
|
||||
] as const;
|
||||
|
||||
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
||||
@@ -275,6 +284,7 @@ export function MobileTabBar() {
|
||||
const mobileTabs = [
|
||||
{ href: "/dashboard", label: t`Home`, icon: IconHome },
|
||||
{ href: "/explore", label: t`Explore`, icon: IconCompass },
|
||||
{ href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent },
|
||||
{ href: "/settings", label: t`Settings`, icon: IconSettings },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PersonHero } from "./person-hero";
|
||||
|
||||
export function PersonDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:gap-8">
|
||||
<Skeleton className="size-40 shrink-0 self-center rounded-2xl sm:size-56 sm:self-start" />
|
||||
<div className="flex-1 space-y-3">
|
||||
@@ -62,7 +62,7 @@ export function PersonDetailClient({ id }: { id: string }) {
|
||||
if (!person) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<PersonHero person={person} />
|
||||
<FilmographyGrid credits={filmography} userStatuses={userStatuses} />
|
||||
<div ref={sentinelRef} />
|
||||
|
||||
@@ -15,7 +15,7 @@ const sectionVariants = {
|
||||
export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-2xl space-y-8"
|
||||
className="mx-auto max-w-2xl space-y-6"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Route as AppRouteImport } from './routes/_app'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AuthRegisterRouteImport } from './routes/_auth/register'
|
||||
import { Route as AuthLoginRouteImport } from './routes/_auth/login'
|
||||
import { Route as AppUpcomingRouteImport } from './routes/_app/upcoming'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
import { Route as AppExploreRouteImport } from './routes/_app/explore'
|
||||
import { Route as AppDashboardRouteImport } from './routes/_app/dashboard'
|
||||
@@ -49,6 +50,11 @@ const AuthLoginRoute = AuthLoginRouteImport.update({
|
||||
path: '/login',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AppUpcomingRoute = AppUpcomingRouteImport.update({
|
||||
id: '/upcoming',
|
||||
path: '/upcoming',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppSettingsRoute = AppSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -81,6 +87,7 @@ export interface FileRoutesByFullPath {
|
||||
'/dashboard': typeof AppDashboardRoute
|
||||
'/explore': typeof AppExploreRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/upcoming': typeof AppUpcomingRoute
|
||||
'/login': typeof AuthLoginRoute
|
||||
'/register': typeof AuthRegisterRoute
|
||||
'/people/$id': typeof AppPeopleIdRoute
|
||||
@@ -92,6 +99,7 @@ export interface FileRoutesByTo {
|
||||
'/dashboard': typeof AppDashboardRoute
|
||||
'/explore': typeof AppExploreRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/upcoming': typeof AppUpcomingRoute
|
||||
'/login': typeof AuthLoginRoute
|
||||
'/register': typeof AuthRegisterRoute
|
||||
'/people/$id': typeof AppPeopleIdRoute
|
||||
@@ -106,6 +114,7 @@ export interface FileRoutesById {
|
||||
'/_app/dashboard': typeof AppDashboardRoute
|
||||
'/_app/explore': typeof AppExploreRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
'/_app/upcoming': typeof AppUpcomingRoute
|
||||
'/_auth/login': typeof AuthLoginRoute
|
||||
'/_auth/register': typeof AuthRegisterRoute
|
||||
'/_app/people/$id': typeof AppPeopleIdRoute
|
||||
@@ -119,6 +128,7 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/explore'
|
||||
| '/settings'
|
||||
| '/upcoming'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/people/$id'
|
||||
@@ -130,6 +140,7 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/explore'
|
||||
| '/settings'
|
||||
| '/upcoming'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/people/$id'
|
||||
@@ -143,6 +154,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/dashboard'
|
||||
| '/_app/explore'
|
||||
| '/_app/settings'
|
||||
| '/_app/upcoming'
|
||||
| '/_auth/login'
|
||||
| '/_auth/register'
|
||||
| '/_app/people/$id'
|
||||
@@ -200,6 +212,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthLoginRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_app/upcoming': {
|
||||
id: '/_app/upcoming'
|
||||
path: '/upcoming'
|
||||
fullPath: '/upcoming'
|
||||
preLoaderRoute: typeof AppUpcomingRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/settings': {
|
||||
id: '/_app/settings'
|
||||
path: '/settings'
|
||||
@@ -242,6 +261,7 @@ interface AppRouteChildren {
|
||||
AppDashboardRoute: typeof AppDashboardRoute
|
||||
AppExploreRoute: typeof AppExploreRoute
|
||||
AppSettingsRoute: typeof AppSettingsRoute
|
||||
AppUpcomingRoute: typeof AppUpcomingRoute
|
||||
AppPeopleIdRoute: typeof AppPeopleIdRoute
|
||||
AppTitlesIdRoute: typeof AppTitlesIdRoute
|
||||
}
|
||||
@@ -250,6 +270,7 @@ const AppRouteChildren: AppRouteChildren = {
|
||||
AppDashboardRoute: AppDashboardRoute,
|
||||
AppExploreRoute: AppExploreRoute,
|
||||
AppSettingsRoute: AppSettingsRoute,
|
||||
AppUpcomingRoute: AppUpcomingRoute,
|
||||
AppPeopleIdRoute: AppPeopleIdRoute,
|
||||
AppTitlesIdRoute: AppTitlesIdRoute,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RecommendationsSection } from "@/components/dashboard/recommendations-s
|
||||
import { StatsSectionSkeleton } from "@/components/dashboard/stats-display";
|
||||
import { StatsSection } from "@/components/dashboard/stats-section";
|
||||
import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
|
||||
import { UpcomingSection } from "@/components/dashboard/upcoming-section";
|
||||
import { WelcomeHeader } from "@/components/dashboard/welcome-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
@@ -19,6 +20,17 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.continueWatching.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.recommendations.queryOptions()),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
),
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.dashboard.library.infiniteOptions({
|
||||
input: (pageParam: number) => ({ page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
},
|
||||
pendingComponent: DashboardSkeleton,
|
||||
@@ -27,7 +39,7 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-2 h-4 w-48" />
|
||||
@@ -43,10 +55,11 @@ function DashboardSkeleton() {
|
||||
function DashboardPage() {
|
||||
const { session } = Route.useRouteContext();
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<WelcomeHeader name={session.user.name} />
|
||||
<StatsSection />
|
||||
<ContinueWatchingSection />
|
||||
<UpcomingSection />
|
||||
<LibrarySection />
|
||||
<RecommendationsSection />
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,14 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.explore.trending.infiniteOptions({
|
||||
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
}),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||
),
|
||||
@@ -35,7 +43,7 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
|
||||
function ExploreSkeletons() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-[320px] rounded-none" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-4">
|
||||
@@ -114,7 +122,7 @@ function ExplorePage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
{hero && (
|
||||
<HeroBanner
|
||||
id={hero.id}
|
||||
|
||||
@@ -51,7 +51,7 @@ export const Route = createFileRoute("/_app/settings")({
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
|
||||
@@ -47,7 +47,7 @@ function TitleDetailPage() {
|
||||
const themeStyle = getThemeCssProperties(title.colorPalette);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-10" style={themeStyle}>
|
||||
<div className="relative space-y-6" style={themeStyle}>
|
||||
<TitleTheme style={themeStyle as Record<string, string>} />
|
||||
<TitleProvider
|
||||
key={title.id}
|
||||
@@ -74,7 +74,7 @@ function TitleDetailPage() {
|
||||
|
||||
function TitleDetailLoading() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
|
||||
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
|
||||
<Skeleton className="aspect-[2/3] w-[140px] shrink-0 self-center rounded-xl md:w-[220px] md:self-start" />
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconCalendarEvent } from "@tabler/icons-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
||||
|
||||
export const Route = createFileRoute("/_app/upcoming")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
pendingComponent: UpcomingSkeleton,
|
||||
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 { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
});
|
||||
|
||||
if (isPending) return <UpcomingSkeleton />;
|
||||
|
||||
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UpcomingHeader />
|
||||
<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">
|
||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buckets = groupByDateBucket(allItems);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<UpcomingHeader />
|
||||
{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() {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight">{t`Upcoming`}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Episodes and movies coming up in the next 90 days.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user