Add instant navigation from search results via TMDB IDs in URL

Navigate instantly to /titles/tmdb-{id}-{type} and show a skeleton
while the new /api/titles/resolve endpoint handles the full import
(TMDB fetch, availability, recommendations, colors). Existing titles
resolve in ~5ms; the URL is replaced with the UUID once resolved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 15:13:51 -05:00
co-authored by Claude Opus 4.6
parent 18385386d5
commit 081f53beac
9 changed files with 175 additions and 140 deletions
+2 -19
View File
@@ -1,8 +1,7 @@
"use client";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card";
@@ -41,7 +40,6 @@ const staggerItem = {
};
export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) {
const router = useRouter();
const [mediaType, setMediaType] = useState<"movie" | "tv">("movie");
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const [results, setResults] = useState<DiscoverResult[]>([]);
@@ -82,21 +80,6 @@ export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) {
};
}, [selectedGenre, mediaType]);
const handleImport = useCallback(
async (tmdbId: number, type: "movie" | "tv") => {
const res = await fetch("/api/titles/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId, type }),
});
const data = await res.json();
if (data.id) {
router.push(`/titles/${data.id}`);
}
},
[router],
);
return (
<section className="space-y-5">
<div className="flex items-center justify-between">
@@ -173,7 +156,7 @@ export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) {
posterPath={r.posterPath}
releaseDate={r.releaseDate}
voteAverage={r.voteAverage}
onImport={() => handleImport(r.tmdbId, r.type)}
href={`/titles/tmdb-${r.tmdbId}-${r.type}`}
/>
</motion.div>
))}
+8 -33
View File
@@ -1,10 +1,9 @@
"use client";
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react";
import { IconPlus, IconStar } from "@tabler/icons-react";
import { motion } from "motion/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
interface HeroBannerProps {
tmdbId: number;
@@ -23,25 +22,7 @@ export function HeroBanner({
backdropPath,
voteAverage,
}: HeroBannerProps) {
const router = useRouter();
const [importing, setImporting] = useState(false);
async function handleImport() {
setImporting(true);
try {
const res = await fetch("/api/titles/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId, type }),
});
const data = await res.json();
if (data.id) {
router.push(`/titles/${data.id}`);
}
} finally {
setImporting(false);
}
}
const href = `/titles/tmdb-${tmdbId}-${type}`;
return (
<motion.div
@@ -101,19 +82,13 @@ export function HeroBanner({
<p className="mt-2 line-clamp-2 max-w-2xl text-sm text-muted-foreground">
{overview}
</p>
<button
type="button"
onClick={handleImport}
disabled={importing}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20 disabled:opacity-50"
<Link
href={href}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
>
{importing ? (
<IconLoader2 size={16} className="animate-spin" />
) : (
<IconPlus size={16} />
)}
<IconPlus size={16} />
Add to Library
</button>
</Link>
</motion.div>
</div>
</div>
+1 -20
View File
@@ -1,8 +1,6 @@
"use client";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { TitleCard } from "@/components/title-card";
interface TitleRowItem {
@@ -36,23 +34,6 @@ const staggerItem = {
};
export function TitleRow({ heading, icon, items }: TitleRowProps) {
const router = useRouter();
const handleImport = useCallback(
async (tmdbId: number, type: "movie" | "tv") => {
const res = await fetch("/api/titles/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId, type }),
});
const data = await res.json();
if (data.id) {
router.push(`/titles/${data.id}`);
}
},
[router],
);
if (items.length === 0) return null;
return (
@@ -80,7 +61,7 @@ export function TitleRow({ heading, icon, items }: TitleRowProps) {
posterPath={item.posterPath}
releaseDate={item.releaseDate}
voteAverage={item.voteAverage}
onImport={() => handleImport(item.tmdbId, item.type)}
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
/>
</motion.div>
))}
+66 -12
View File
@@ -106,6 +106,8 @@ const staggerItem = {
},
};
const TMDB_ID_PATTERN = /^tmdb-(\d+)-(movie|tv)$/;
export default function TitleDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
@@ -117,12 +119,56 @@ export default function TitleDetailPage() {
const [openSeason, setOpenSeason] = useState<number | null>(null);
const [watchingEp, setWatchingEp] = useState<string | null>(null);
// Parse TMDB ID format: tmdb-{id}-{type}
const tmdbMatch = TMDB_ID_PATTERN.exec(id);
const isTmdbFormat = !!tmdbMatch;
const [resolvedId, setResolvedId] = useState<string | null>(
isTmdbFormat ? null : id,
);
const [resolveError, setResolveError] = useState<string | null>(null);
// Resolve TMDB ID to internal UUID
useEffect(() => {
if (!isTmdbFormat) return;
const tmdbId = Number(tmdbMatch[1]);
const type = tmdbMatch[2] as "movie" | "tv";
let cancelled = false;
fetch("/api/titles/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId, type }),
})
.then((res) => {
if (!res.ok) throw new Error("Failed to resolve title");
return res.json();
})
.then((data) => {
if (cancelled) return;
if (data.id) {
setResolvedId(data.id);
router.replace(`/titles/${data.id}`, { scroll: false });
} else {
setResolveError("Title could not be imported");
}
})
.catch(() => {
if (!cancelled) setResolveError("Failed to load title");
});
return () => {
cancelled = true;
};
}, [isTmdbFormat, tmdbMatch, router]);
const fetchTitle = useCallback(async () => {
if (!resolvedId) return;
setLoading(true);
try {
const [titleRes, statusRes] = await Promise.all([
fetch(`/api/titles/${id}`),
fetch(`/api/titles/${id}/status`),
fetch(`/api/titles/${resolvedId}`),
fetch(`/api/titles/${resolvedId}/status`),
]);
const titleData = await titleRes.json();
let statusData = { status: null, rating: null, episodeWatches: [] };
@@ -138,11 +184,12 @@ export default function TitleDetailPage() {
} finally {
setLoading(false);
}
}, [id]);
}, [resolvedId]);
const fetchRecommendations = useCallback(async () => {
if (!resolvedId) return;
try {
const res = await fetch(`/api/titles/${id}/recommendations`);
const res = await fetch(`/api/titles/${resolvedId}/recommendations`);
if (res.ok) {
const data = await res.json();
setRecommendations(data ?? []);
@@ -150,7 +197,7 @@ export default function TitleDetailPage() {
} catch {
// silent
}
}, [id]);
}, [resolvedId]);
useEffect(() => {
fetchTitle();
@@ -168,7 +215,7 @@ export default function TitleDetailPage() {
// Optimistic update
setTitle((t) => (t ? { ...t, userStatus: status } : t));
try {
const res = await fetch(`/api/titles/${id}/status`, {
const res = await fetch(`/api/titles/${resolvedId}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
@@ -191,7 +238,7 @@ export default function TitleDetailPage() {
toast.error("Failed to update status");
}
},
[id, title?.userStatus],
[resolvedId, title?.userStatus],
);
const handleRating = useCallback(
@@ -200,7 +247,7 @@ export default function TitleDetailPage() {
// Optimistic update
setTitle((t) => (t ? { ...t, userRating: ratingStars } : t));
try {
const res = await fetch(`/api/titles/${id}/rating`, {
const res = await fetch(`/api/titles/${resolvedId}/rating`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ratingStars }),
@@ -216,13 +263,15 @@ export default function TitleDetailPage() {
toast.error("Failed to update rating");
}
},
[id, title?.userRating],
[resolvedId, title?.userRating],
);
const handleWatchMovie = useCallback(async () => {
setTitle((t) => (t ? { ...t, userStatus: "completed" } : t));
try {
const res = await fetch(`/api/movies/${id}/watch`, { method: "POST" });
const res = await fetch(`/api/movies/${resolvedId}/watch`, {
method: "POST",
});
if (!res.ok) throw new Error();
toast.success(`Marked "${title?.title}" as watched`);
} catch {
@@ -231,7 +280,7 @@ export default function TitleDetailPage() {
);
toast.error("Failed to mark as watched");
}
}, [id, title?.title, title?.userStatus]);
}, [resolvedId, title?.title, title?.userStatus]);
const handleWatchEpisode = useCallback(
async (
@@ -353,7 +402,12 @@ export default function TitleDetailPage() {
});
}
if (loading) {
if (resolveError) {
return (
<p className="py-24 text-center text-muted-foreground">{resolveError}</p>
);
}
if (!resolvedId || loading) {
return <TitleDetailSkeleton />;
}
if (!title) {