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
+3 -21
View File
@@ -69,7 +69,6 @@ export function CommandPalette() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<number | null>(null);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const debouncedQuery = useDebounce(query, 300);
@@ -135,22 +134,9 @@ export function CommandPalette() {
}, [debouncedQuery]);
const handleSelect = useCallback(
async (result: SearchResult) => {
setImporting(result.tmdbId);
try {
const res = await fetch("/api/titles/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId: result.tmdbId, type: result.type }),
});
const title = await res.json();
if (title.id) {
setCommandPaletteOpen(false);
router.push(`/titles/${title.id}`);
}
} finally {
setImporting(null);
}
(result: SearchResult) => {
setCommandPaletteOpen(false);
router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`);
},
[router, setCommandPaletteOpen],
);
@@ -205,7 +191,6 @@ export function CommandPalette() {
<CommandItem
key={`${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
disabled={importing === r.tmdbId}
className="flex items-center gap-3 py-2"
>
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
@@ -237,9 +222,6 @@ export function CommandPalette() {
)}
</div>
</div>
{importing === r.tmdbId && (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</CommandItem>
))}
</CommandGroup>
+4 -24
View File
@@ -34,7 +34,6 @@ export function SearchAutocomplete({
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<"all" | "movie" | "tv">("all");
const [importing, setImporting] = useState<number | null>(null);
const debouncedQuery = useDebounce(query, 300);
const inputRef = useRef<HTMLInputElement>(null);
@@ -79,25 +78,9 @@ export function SearchAutocomplete({
};
}, [debouncedQuery, filter, onResults, onLoading]);
const handleImport = useCallback(
async (result: SearchResult) => {
setImporting(result.tmdbId);
try {
const res = await fetch("/api/titles/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId: result.tmdbId, type: result.type }),
});
const title = await res.json();
if (title.id) {
toast.success(`Added "${result.title}" to library`);
router.push(`/titles/${title.id}`);
}
} catch {
toast.error("Failed to import title");
} finally {
setImporting(null);
}
const handleSelect = useCallback(
(result: SearchResult) => {
router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`);
},
[router],
);
@@ -159,7 +142,7 @@ export function SearchAutocomplete({
<CommandPrimitive.Item
key={`${r.type}-${r.tmdbId}`}
value={`${r.type}-${r.tmdbId}`}
onSelect={() => handleImport(r)}
onSelect={() => handleSelect(r)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm data-[selected=true]:bg-accent"
>
<div className="h-[60px] w-10 shrink-0 overflow-hidden rounded-md bg-muted">
@@ -200,9 +183,6 @@ export function SearchAutocomplete({
</p>
)}
</div>
{importing === r.tmdbId && (
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</CommandPrimitive.Item>
))}
</CommandPrimitive.List>