Replace tmdb-* URL routing with resolveTitle/resolvePerson server actions

- Add `resolveTitle` and `resolvePerson` server actions that import
  from TMDB and return the internal DB id
- Remove `tmdb-{id}-{type}` URL pattern and server-side redirect logic
  from TitleDetailPage and PersonDetailPage
- Rename `importTitle` → `getOrFetchTitleByTmdbId`; add `getOrFetchTitle`
  combining fetch + children lookup
- Update HeroBanner, TitleCard, and CommandPalette to call resolve
  actions client-side before pushing to router
- Batch availability offer inserts into a single transaction
This commit is contained in:
2026-03-07 14:27:03 -05:00
parent ccd482db4e
commit 8a45aa1c36
17 changed files with 552 additions and 419 deletions
@@ -2,7 +2,10 @@
import { IconPlus, IconStar } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useProgress } from "@/components/navigation-progress";
import { resolveTitle } from "@/lib/actions/titles";
interface HeroBannerProps {
tmdbId: number;
@@ -21,7 +24,18 @@ export function HeroBanner({
backdropPath,
voteAverage,
}: HeroBannerProps) {
const href = `/titles/tmdb-${tmdbId}-${type}`;
const router = useRouter();
const progress = useProgress();
const [isPending, startTransition] = useTransition();
function handleNavigate() {
if (isPending) return;
progress.start();
startTransition(async () => {
const id = await resolveTitle(tmdbId, type);
if (id) router.push(`/titles/${id}`);
});
}
return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
@@ -67,21 +81,28 @@ export function HeroBanner({
Trending today
</span>
</div>
<Link href={href} className="group/title">
<button
type="button"
className="group/title cursor-pointer text-left"
onClick={handleNavigate}
disabled={isPending}
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title}
</h2>
</Link>
</button>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview}
</p>
<Link
href={href}
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"
<button
type="button"
onClick={handleNavigate}
disabled={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"
>
<IconPlus aria-hidden={true} className="size-4" />
Add to Library
</Link>
</button>
</div>
</div>
</div>
+2 -15
View File
@@ -4,25 +4,17 @@ import { notFound } from "next/navigation";
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { persons } from "@/lib/db/schema";
import {
getLocalFilmography,
getOrFetchPerson,
getOrFetchPersonByTmdbId,
} from "@/lib/services/person";
import { getLocalFilmography, getOrFetchPerson } from "@/lib/services/person";
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
import { FilmographyGrid } from "./_components/filmography-grid";
import { PersonHero } from "./_components/person-hero";
const TMDB_PATTERN = /^tmdb-(\d+)$/;
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
if (TMDB_PATTERN.test(id)) return { title: "Sofa" };
const person = db.select().from(persons).where(eq(persons.id, id)).get();
if (!person) return { title: "Not Found — Sofa" };
@@ -38,13 +30,8 @@ export default async function PersonDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
await getSession();
const tmdbMatch = TMDB_PATTERN.exec(id);
const person = tmdbMatch
? await getOrFetchPersonByTmdbId(Number(tmdbMatch[1]))
: await getOrFetchPerson(id);
const person = await getOrFetchPerson(id);
if (!person) notFound();
const filmography = getLocalFilmography(person.id);
+3 -18
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import {
RecommendationsSkeleton,
@@ -9,7 +9,7 @@ import {
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { titles } from "@/lib/db/schema";
import { getTitleWithChildren, importTitle } from "@/lib/services/metadata";
import { getOrFetchTitle } from "@/lib/services/metadata";
import { getUserTitleInfo } from "@/lib/services/tracking";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { getTitleThemeStyle } from "@/lib/utils/title-theme";
@@ -23,16 +23,12 @@ import { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations";
import { TitleSeasons } from "./_components/title-seasons";
const TMDB_ID_PATTERN = /^tmdb-(\d+)-(movie|tv)$/;
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
if (TMDB_ID_PATTERN.test(id)) return { title: "Sofa" };
const title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return { title: "Not Found — Sofa" };
@@ -56,21 +52,10 @@ export default async function TitleDetailPage({
}) {
const { id } = await params;
// TMDB ID resolution: tmdb-{id}-{type} → import + redirect
const tmdbMatch = TMDB_ID_PATTERN.exec(id);
if (tmdbMatch) {
const title = await importTitle(
Number(tmdbMatch[1]),
tmdbMatch[2] as "movie" | "tv",
);
if (!title) notFound();
redirect(`/titles/${title.id}`);
}
// Fetch title + user info in parallel
const session = await getSession();
const [result, userInfo] = await Promise.all([
getTitleWithChildren(id),
getOrFetchTitle(id),
session ? getUserTitleInfo(session.user.id, id) : null,
]);
if (!result) notFound();