mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user