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