feat(web): add route loaders to prefetch dashboard, explore, and settings queries

This commit is contained in:
2026-03-18 22:04:19 -04:00
parent 9db4285bc9
commit d94ec16fae
17 changed files with 287 additions and 204 deletions
+4
View File
@@ -298,6 +298,10 @@ export function startJobs() {
schedule("telemetryReport", "30 0 * * *", async () => {
await performTelemetryReport();
});
schedule("optimizeDb", "0 4 * * 0", async () => {
const { optimizeDatabase } = await import("@sofa/db/client");
optimizeDatabase();
});
log.info(`Started ${jobs.size} jobs`);
}
+1
View File
@@ -50,6 +50,7 @@ app.use(
cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
credentials: true,
maxAge: 86400,
}),
);
-1
View File
@@ -34,7 +34,6 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"jotai": "2.18.1",
"media-chrome": "4.18.1",
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCheck, IconLibrary, IconMovie, IconPlayerPlay } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Suspense, lazy, useState } from "react";
import {
Select,
@@ -14,7 +14,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas";
import { Sparkline } from "./sparkline";
const Sparkline = lazy(() => import("./sparkline").then((m) => ({ default: m.Sparkline })));
function StatCardSkeleton() {
return (
@@ -66,7 +66,11 @@ function StatCard({
className="animate-stagger-item border-border/30 bg-card/50 relative overflow-hidden rounded-xl border p-4"
style={{ "--stagger-index": index } as React.CSSProperties}
>
{sparklineData && <Sparkline data={sparklineData} color={color} />}
{sparklineData && (
<Suspense>
<Sparkline data={sparklineData} color={color} />
</Suspense>
)}
<div className="relative z-10 flex items-center gap-2">
<div className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}>
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
@@ -1,140 +0,0 @@
import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import { FilterableTitleRow } from "./filterable-title-row";
import { HeroBanner } from "./hero-banner";
import { TitleRow } from "./title-row";
function ExploreSkeletons() {
return (
<div className="space-y-10">
<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">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-32" />
</div>
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: 8 }).map((_, j) => (
<div key={j} className="w-[140px] shrink-0 sm:w-[160px]">
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
</div>
))}
</div>
</div>
))}
</div>
);
}
function mergeMaps<T>(...maps: (Record<string, T> | undefined)[]): Record<string, T> {
return Object.assign({}, ...maps.filter(Boolean));
}
export function ExploreClient() {
const { t } = useLingui();
const {
data: trendingData,
isPending: trendingPending,
fetchNextPage: fetchNextTrending,
hasNextPage: hasNextTrending,
isFetchingNextPage: isFetchingNextTrending,
} = useInfiniteQuery(
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,
}),
);
const { data: popularMoviesData, isPending: moviesPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
const { data: popularTvData, isPending: tvPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
const { data: movieGenreData } = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
);
const { data: tvGenreData } = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
);
const isPending = trendingPending || moviesPending || tvPending;
const trendingItems = useMemo(
() => trendingData?.pages.flatMap((p) => p.items) ?? [],
[trendingData?.pages],
);
const hero = trendingData?.pages[0]?.hero ?? null;
if (isPending) return <ExploreSkeletons />;
// Merge user statuses and episode progress from all responses
const userStatuses = mergeMaps(
...(trendingData?.pages.map((p) => p.userStatuses) ?? []),
popularMoviesData?.userStatuses,
popularTvData?.userStatuses,
);
const episodeProgress = mergeMaps(
...(trendingData?.pages.map((p) => p.episodeProgress) ?? []),
popularMoviesData?.episodeProgress,
popularTvData?.episodeProgress,
);
return (
<div className="space-y-10">
{hero && (
<HeroBanner
id={hero.id}
type={hero.type}
title={hero.title}
overview={hero.overview}
backdropPath={hero.backdropPath}
voteAverage={hero.voteAverage}
/>
)}
<div>
<TitleRow
heading={t`Trending Today`}
icon={<IconFlame aria-hidden={true} className="text-primary size-5" />}
items={trendingItems}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
onEndReached={fetchNextTrending}
hasNextPage={hasNextTrending}
isFetchingNextPage={isFetchingNextTrending}
/>
</div>
<FilterableTitleRow
heading={t`Popular Movies`}
icon={<IconMovie aria-hidden={true} className="text-primary size-5" />}
mediaType="movie"
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
genres={movieGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<FilterableTitleRow
heading={t`Popular TV Shows`}
icon={<IconDeviceTv aria-hidden={true} className="text-primary size-5" />}
mediaType="tv"
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
genres={tvGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
</div>
);
}
+7
View File
@@ -10,6 +10,13 @@ import type { contract } from "@sofa/api/contract";
import { i18n } from "@sofa/i18n";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 10 * 60_000,
refetchOnWindowFocus: false,
},
},
queryCache: new QueryCache({
onError: (_error, query) => {
toast.error(i18n._(msg`Something went wrong…`), {
+28
View File
@@ -1,15 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
import { ContinueWatchingSectionSkeleton } from "@/components/dashboard/continue-watching-list";
import { ContinueWatchingSection } from "@/components/dashboard/continue-watching-section";
import { LibrarySection } from "@/components/dashboard/library-section";
import { RecommendationsSection } from "@/components/dashboard/recommendations-section";
import { StatsSectionSkeleton } from "@/components/dashboard/stats-display";
import { StatsSection } from "@/components/dashboard/stats-section";
import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
import { WelcomeHeader } from "@/components/dashboard/welcome-header";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/dashboard")({
loader: async ({ context }) => {
await Promise.all([
context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
context.queryClient.ensureQueryData(orpc.dashboard.continueWatching.queryOptions()),
context.queryClient.ensureQueryData(orpc.dashboard.recommendations.queryOptions()),
]);
},
pendingComponent: DashboardSkeleton,
component: DashboardPage,
});
function DashboardSkeleton() {
return (
<div className="space-y-10">
<div>
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-2 h-4 w-48" />
</div>
<StatsSectionSkeleton />
<ContinueWatchingSectionSkeleton />
<TitleGridSectionSkeleton />
<TitleGridSectionSkeleton />
</div>
);
}
function DashboardPage() {
const { session } = Route.useRouteContext();
return (
+154 -2
View File
@@ -1,7 +1,159 @@
import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useMemo } from "react";
import { ExploreClient } from "@/components/explore/explore-client";
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner";
import { TitleRow } from "@/components/explore/title-row";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/explore")({
component: () => <ExploreClient />,
loader: ({ context }) => {
context.queryClient.ensureQueryData(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
context.queryClient.ensureQueryData(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
context.queryClient.ensureQueryData(
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
);
context.queryClient.ensureQueryData(
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
);
},
pendingComponent: ExploreSkeletons,
component: ExplorePage,
});
function ExploreSkeletons() {
return (
<div className="space-y-10">
<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">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-32" />
</div>
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: 8 }).map((_, j) => (
<div key={j} className="w-[140px] shrink-0 sm:w-[160px]">
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
</div>
))}
</div>
</div>
))}
</div>
);
}
function mergeMaps<T>(...maps: (Record<string, T> | undefined)[]): Record<string, T> {
return Object.assign({}, ...maps.filter(Boolean));
}
function ExplorePage() {
const { t } = useLingui();
const {
data: trendingData,
isPending: trendingPending,
fetchNextPage: fetchNextTrending,
hasNextPage: hasNextTrending,
isFetchingNextPage: isFetchingNextTrending,
} = useInfiniteQuery(
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,
}),
);
const { data: popularMoviesData, isPending: moviesPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
const { data: popularTvData, isPending: tvPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
const { data: movieGenreData } = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
);
const { data: tvGenreData } = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
);
const isPending = trendingPending || moviesPending || tvPending;
const trendingItems = useMemo(
() => trendingData?.pages.flatMap((p) => p.items) ?? [],
[trendingData?.pages],
);
const hero = trendingData?.pages[0]?.hero ?? null;
if (isPending) return <ExploreSkeletons />;
// Merge user statuses and episode progress from all responses
const userStatuses = mergeMaps(
...(trendingData?.pages.map((p) => p.userStatuses) ?? []),
popularMoviesData?.userStatuses,
popularTvData?.userStatuses,
);
const episodeProgress = mergeMaps(
...(trendingData?.pages.map((p) => p.episodeProgress) ?? []),
popularMoviesData?.episodeProgress,
popularTvData?.episodeProgress,
);
return (
<div className="space-y-10">
{hero && (
<HeroBanner
id={hero.id}
type={hero.type}
title={hero.title}
overview={hero.overview}
backdropPath={hero.backdropPath}
voteAverage={hero.voteAverage}
/>
)}
<div>
<TitleRow
heading={t`Trending Today`}
icon={<IconFlame aria-hidden={true} className="text-primary size-5" />}
items={trendingItems}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
onEndReached={fetchNextTrending}
hasNextPage={hasNextTrending}
isFetchingNextPage={isFetchingNextTrending}
/>
</div>
<FilterableTitleRow
heading={t`Popular Movies`}
icon={<IconMovie aria-hidden={true} className="text-primary size-5" />}
mediaType="movie"
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
genres={movieGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<FilterableTitleRow
heading={t`Popular TV Shows`}
icon={<IconDeviceTv aria-hidden={true} className="text-primary size-5" />}
mediaType="tv"
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
genres={tvGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
</div>
);
}
+36
View File
@@ -22,13 +22,49 @@ import { SystemHealthCards } from "@/components/settings/system-health-section";
import { UpdateCheckSection } from "@/components/settings/update-check-section";
import { TmdbLogo } from "@/components/tmdb-logo";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
const GITHUB_REPO = "jakejarvis/sofa";
export const Route = createFileRoute("/_app/settings")({
loader: async ({ context }) => {
const promises: Promise<unknown>[] = [
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
context.queryClient.ensureQueryData(orpc.system.status.queryOptions()),
];
const isAdmin = context.session.user.role === "admin";
if (isAdmin) {
promises.push(
context.queryClient.ensureQueryData(orpc.admin.systemHealth.queryOptions()),
context.queryClient.ensureQueryData(orpc.admin.backups.list.queryOptions()),
context.queryClient.ensureQueryData(orpc.admin.backups.schedule.queryOptions()),
);
}
await Promise.all(promises);
},
pendingComponent: SettingsSkeleton,
component: SettingsPage,
});
function SettingsSkeleton() {
return (
<div className="mx-auto max-w-2xl space-y-8">
<div>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-8 w-32" />
</div>
<Skeleton className="mt-2 h-4 w-56" />
</div>
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
);
}
function SettingsPage() {
const { session } = Route.useRouteContext();
const isAdmin = session.user.role === "admin";