mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Replace text-based MOVIE/TV badges with Tabler icons (IconMovie, IconDeviceTv), add proper padding to the metadata section below posters, use IconStarFilled for ratings, and add subtle image hover zoom. Update skeleton and search autocomplete to match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
IconDeviceTv,
|
|
IconMovie,
|
|
IconSearch,
|
|
IconStarFilled,
|
|
} from "@tabler/icons-react";
|
|
import { Command as CommandPrimitive } from "cmdk";
|
|
import Image from "next/image";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { useDebounce } from "@/hooks/use-debounce";
|
|
|
|
interface SearchResult {
|
|
tmdbId: number;
|
|
type: "movie" | "tv";
|
|
title: string;
|
|
overview: string;
|
|
releaseDate: string | null;
|
|
posterPath: string | null;
|
|
popularity: number;
|
|
voteAverage: number;
|
|
}
|
|
|
|
interface SearchAutocompleteProps {
|
|
onResults?: (results: SearchResult[]) => void;
|
|
onLoading?: (loading: boolean) => void;
|
|
}
|
|
|
|
export function SearchAutocomplete({
|
|
onResults,
|
|
onLoading,
|
|
}: SearchAutocompleteProps) {
|
|
const router = useRouter();
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const [filter, setFilter] = useState<"all" | "movie" | "tv">("all");
|
|
const debouncedQuery = useDebounce(query, 300);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!debouncedQuery.trim()) {
|
|
setResults([]);
|
|
onResults?.([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
onLoading?.(true);
|
|
const typeParam = filter !== "all" ? `&type=${filter}` : "";
|
|
fetch(`/api/search?query=${encodeURIComponent(debouncedQuery)}${typeParam}`)
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (!cancelled) {
|
|
if (data.code === "TMDB_NOT_CONFIGURED") {
|
|
toast.error("TMDB API key not configured", {
|
|
description: "Visit /setup for instructions.",
|
|
action: {
|
|
label: "Setup",
|
|
onClick: () => (window.location.href = "/setup"),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
const res = data.results ?? [];
|
|
setResults(res);
|
|
onResults?.(res);
|
|
setOpen(res.length > 0);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
onLoading?.(false);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [debouncedQuery, filter, onResults, onLoading]);
|
|
|
|
const handleSelect = useCallback(
|
|
(result: SearchResult) => {
|
|
router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Filter tabs */}
|
|
<div className="flex gap-1">
|
|
{(["all", "movie", "tv"] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setFilter(t)}
|
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
filter === t
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
}`}
|
|
>
|
|
{t === "all" ? "All" : t === "movie" ? "Movies" : "TV Shows"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<CommandPrimitive shouldFilter={false} className="w-full">
|
|
<div className="relative">
|
|
<IconSearch
|
|
size={18}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<CommandPrimitive.Input
|
|
ref={inputRef}
|
|
placeholder="Search movies & TV shows..."
|
|
value={query}
|
|
onValueChange={(val) => {
|
|
setQuery(val);
|
|
if (val.trim()) setOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
if (results.length > 0) setOpen(true);
|
|
}}
|
|
onBlur={() => {
|
|
// Delay to allow click on result
|
|
setTimeout(() => setOpen(false), 200);
|
|
}}
|
|
className="flex h-13 w-full rounded-xl border border-border/50 bg-card/50 pl-11 pr-4 text-base backdrop-blur-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
|
/>
|
|
{loading && (
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{open && results.length > 0 && (
|
|
<CommandPrimitive.List className="absolute z-30 mt-2 max-h-96 w-full overflow-y-auto rounded-xl border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl">
|
|
{results.map((r) => (
|
|
<CommandPrimitive.Item
|
|
key={`${r.type}-${r.tmdbId}`}
|
|
value={`${r.type}-${r.tmdbId}`}
|
|
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">
|
|
{r.posterPath ? (
|
|
<Image
|
|
src={r.posterPath as string}
|
|
alt={r.title}
|
|
width={40}
|
|
height={60}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
|
|
?
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="truncate text-sm font-medium">{r.title}</p>
|
|
{r.type === "movie" ? (
|
|
<IconMovie
|
|
size={14}
|
|
className="shrink-0 text-primary/60"
|
|
/>
|
|
) : (
|
|
<IconDeviceTv
|
|
size={14}
|
|
className="shrink-0 text-primary/60"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
{r.releaseDate && (
|
|
<span>{r.releaseDate.slice(0, 4)}</span>
|
|
)}
|
|
{r.voteAverage > 0 && (
|
|
<span className="flex items-center gap-0.5 text-primary/80">
|
|
<IconStarFilled size={11} />
|
|
{r.voteAverage.toFixed(1)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{r.overview && (
|
|
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground/70">
|
|
{r.overview}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.List>
|
|
)}
|
|
</CommandPrimitive>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|