feat: add upcoming episodes and movies timeline (#17)

This commit is contained in:
2026-03-21 12:31:53 -04:00
committed by GitHub
parent 5aaf88591b
commit 1a503df2ae
42 changed files with 2405 additions and 61 deletions
@@ -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>
);
}
+12 -2
View File
@@ -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={{
+21
View File
@@ -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,
}
+15 -2
View File
@@ -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>
+10 -2
View File
@@ -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}
+1 -1
View File
@@ -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" />
+2 -2
View File
@@ -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" />
+122
View File
@@ -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>
);
}