refactor: return pre-resolved internal IDs from all listing endpoints and remove client-side resolve mutations

All explore, discover, search, recommendation, and person-credit listing procedures now include the internal database `id` on every item so clients can navigate and act without a separate resolve round-trip.

- Remove `titles.resolve` and `people.resolve` mutation calls from the native search screen, hero banners, poster rows, and cast cards; replace with direct `Link` navigation using the pre-returned `id`.
- Change `titles.quickAdd` to accept `{ id }` instead of `{ tmdbId, type }` and update every call site on native and web.
- Key all user-status and episode-progress lookups by `id` instead of `tmdbId-type` composite strings across `PosterCard`, `HorizontalPosterRow`, `FilterableTitleRow`, and `usePosterActions`; drop the `tmdbId` prop from `PosterCard` entirely.
- Remove the `titles.hydrateSeasons` auto-trigger from the title detail screen; season hydration now happens server-side on resolve.
- Delete the `browse-thumbhashes` and `browse-title-ids` server procedures and remove them from the router.
- Extend `@sofa/api` schemas with an `id` field on all listing-item types; update `packages/core` services and add a DB migration accordingly.
This commit is contained in:
2026-03-16 16:35:50 -04:00
parent ccc7922737
commit 25736f684a
48 changed files with 6965 additions and 1777 deletions
+7 -40
View File
@@ -8,11 +8,10 @@ import {
IconX,
} from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
import { skipToken, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import {
Command,
@@ -69,6 +68,7 @@ for (const entry of SHORTCUT_DESCRIPTIONS) {
}
interface SearchResult {
id?: string;
tmdbId: number;
type: "movie" | "tv" | "person";
title: string;
@@ -144,51 +144,18 @@ export function CommandPalette() {
};
}, [debouncedQuery, results.length, setRecentSearches]);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/people/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load person");
},
}),
);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const handleSelect = useCallback(
(result: SearchResult) => {
if (!result.id) return;
setCommandPaletteOpen(false);
progress.start();
if (result.type === "person") {
resolvePersonMutation.mutate({ tmdbId: result.tmdbId });
void navigate({ to: "/people/$id", params: { id: result.id } });
} else {
resolveTitleMutation.mutate({
tmdbId: result.tmdbId,
type: result.type,
});
void navigate({ to: "/titles/$id", params: { id: result.id } });
}
},
[
setCommandPaletteOpen,
progress,
resolvePersonMutation,
resolveTitleMutation,
],
[setCommandPaletteOpen, progress, navigate],
);
const handleRecentSearch = useCallback((q: string) => {
@@ -251,7 +218,7 @@ export function CommandPalette() {
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
key={r.id ?? `${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
@@ -3,7 +3,6 @@ import { Skeleton } from "@/components/ui/skeleton";
interface TitleGridItem {
id: string;
tmdbId: number;
type: string;
title: string;
posterPath: string | null;
@@ -43,7 +42,6 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
>
<TitleCard
id={t.id}
tmdbId={t.tmdbId}
type={t.type}
title={t.title}
posterPath={t.posterPath}
@@ -96,7 +96,7 @@ export function ExploreClient() {
<div className="space-y-10">
{hero && (
<HeroBanner
tmdbId={hero.tmdbId}
id={hero.id}
type={hero.type}
title={hero.title}
overview={hero.overview}
@@ -12,7 +12,7 @@ interface Genre {
}
interface TitleRowItem {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
posterPath: string | null;
@@ -177,26 +177,21 @@ export function FilterableTitleRow({
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item: TitleRowItem, i: number) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
id={item.id}
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
userStatus={userStatuses[item.id]}
episodeProgress={episodeProgress[item.id]}
/>
</div>
</div>
+13 -41
View File
@@ -4,15 +4,10 @@ import {
IconPlus,
IconStar,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { orpc } from "@/lib/orpc/client";
import { Link } from "@tanstack/react-router";
interface HeroBannerProps {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
overview: string;
@@ -21,34 +16,13 @@ interface HeroBannerProps {
}
export function HeroBanner({
tmdbId,
id,
type,
title,
overview,
backdropPath,
voteAverage,
}: HeroBannerProps) {
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
function handleNavigate() {
if (resolveMutation.isPending) return;
progress.start();
resolveMutation.mutate({ tmdbId, type });
}
return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
@@ -103,28 +77,26 @@ export function HeroBanner({
Trending today
</span>
</div>
<button
type="button"
className="group/title cursor-pointer text-left"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
<Link
to="/titles/$id"
params={{ id }}
className="group/title text-left"
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title}
</h2>
</button>
</Link>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview}
</p>
<button
type="button"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
className="mt-4 inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20 disabled:opacity-70"
<Link
to="/titles/$id"
params={{ id }}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
>
<IconPlus aria-hidden={true} className="size-4" />
Add to Library
</button>
</Link>
</div>
</div>
</div>
+5 -10
View File
@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
interface TitleRowItem {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
posterPath: string | null;
@@ -70,26 +70,21 @@ export function TitleRow({
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
id={item.id}
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
userStatus={userStatuses?.[item.id]}
episodeProgress={episodeProgress?.[item.id]}
/>
</div>
</div>
@@ -125,7 +125,6 @@ export function FilmographyGrid({
>
<TitleCard
id={credit.titleId}
tmdbId={credit.tmdbId}
type={credit.type}
title={credit.title}
posterPath={credit.posterPath}
+9 -48
View File
@@ -9,11 +9,9 @@ import {
IconStarFilled,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
@@ -57,8 +55,7 @@ interface CardInnerProps {
}
export interface TitleCardProps extends CardInnerProps {
id?: string;
tmdbId: number;
id: string;
}
const statusConfig = {
@@ -80,12 +77,10 @@ const statusConfig = {
} as const;
function QuickAddButton({
tmdbId,
type,
id,
userStatus,
}: {
tmdbId: number;
type: "movie" | "tv";
id: string;
userStatus?: TitleStatus | null;
}) {
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
@@ -112,7 +107,7 @@ function QuickAddButton({
e.preventDefault();
e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ tmdbId, type });
quickAddMutation.mutate({ id });
}
if (isAdded && config) {
@@ -292,7 +287,6 @@ function CardInner({
export function TitleCard({
id,
tmdbId,
type,
title,
posterPath,
@@ -303,21 +297,6 @@ export function TitleCard({
episodeProgress,
}: TitleCardProps) {
const tilt = useTiltEffect();
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id: resolvedId }) => {
if (resolvedId)
void navigate({ to: "/titles/$id", params: { id: resolvedId } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
@@ -341,28 +320,10 @@ export function TitleCard({
return (
<div className="group relative">
<QuickAddButton
tmdbId={tmdbId}
type={type as "movie" | "tv"}
userStatus={userStatus}
/>
{id ? (
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
) : (
<button
type="button"
disabled={resolveMutation.isPending}
className={`w-full text-left ${resolveMutation.isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
onClick={() => {
progress.start();
resolveMutation.mutate({ tmdbId, type: type as "movie" | "tv" });
}}
>
{cardContent}
</button>
)}
<QuickAddButton id={id} userStatus={userStatus} />
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
</div>
);
}
@@ -1,21 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
import { SeasonsSkeleton, TitleSeasons } from "./title-seasons";
export function AsyncTitleSeasons({
titleId,
tmdbId,
}: {
titleId: string;
tmdbId: number;
}) {
const { data, isPending } = useQuery(
orpc.titles.hydrateSeasons.queryOptions({
input: { id: titleId, tmdbId },
}),
);
if (isPending) return <SeasonsSkeleton />;
if (!data?.seasons || data.seasons.length === 0) return null;
return <TitleSeasons seasons={data.seasons} />;
}
@@ -46,7 +46,6 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
>
<TitleCard
id={rec.id}
tmdbId={rec.tmdbId}
type={rec.type}
title={rec.title}
posterPath={rec.posterPath}
+3 -16
View File
@@ -1,6 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Suspense } from "react";
import { AsyncTitleSeasons } from "@/components/titles/async-title-seasons";
import { TitleActions } from "@/components/titles/title-actions";
import { TitleAvailability } from "@/components/titles/title-availability";
import { TitleCast } from "@/components/titles/title-cast";
@@ -8,10 +6,7 @@ import { TitleHero } from "@/components/titles/title-hero";
import { TitleKeyboardShortcuts } from "@/components/titles/title-keyboard-shortcuts";
import { TitleProvider } from "@/components/titles/title-provider";
import { TitleRecommendations } from "@/components/titles/title-recommendations";
import {
SeasonsSkeleton,
TitleSeasons,
} from "@/components/titles/title-seasons";
import { TitleSeasons } from "@/components/titles/title-seasons";
import { TitleTheme } from "@/components/titles/title-theme";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
@@ -46,8 +41,7 @@ export const Route = createFileRoute("/_app/titles/$id")({
});
function TitleDetailPage() {
const { title, seasons, needsHydration, availability, cast } =
Route.useLoaderData();
const { title, seasons, availability, cast } = Route.useLoaderData();
const themeStyle = getThemeCssProperties(title.colorPalette);
@@ -69,14 +63,7 @@ function TitleDetailPage() {
<TitleAvailability availability={availability} />
</TitleHero>
{title.type === "tv" && needsHydration && (
<Suspense fallback={<SeasonsSkeleton />}>
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
</Suspense>
)}
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
<TitleSeasons />
)}
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
<TitleCast cast={cast} titleType={title.type} />