167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } from "react"
|
|
import Image from "next/image"
|
|
import { SearchIcon, XIcon, Gamepad2Icon } from "lucide-react"
|
|
import { useDebounce } from "@/lib/hooks/useDebounce"
|
|
|
|
interface SearchResult {
|
|
id: string
|
|
appId: number | null
|
|
title: string
|
|
image: string | null
|
|
source: string
|
|
}
|
|
|
|
interface GameSelectorProps {
|
|
selectedGames: SearchResult[]
|
|
onSelect: (game: SearchResult) => void
|
|
onRemove: (gameId: string) => void
|
|
maxSelections?: number
|
|
}
|
|
|
|
export function GameSelector({ selectedGames, onSelect, onRemove, maxSelections = 4 }: GameSelectorProps) {
|
|
const [query, setQuery] = useState("")
|
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [open, setOpen] = useState(false)
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
const debouncedQuery = useDebounce(query, 300)
|
|
|
|
useEffect(() => {
|
|
if (debouncedQuery.length < 2) {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
async function fetchResults() {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(`/api/search/unified?q=${encodeURIComponent(debouncedQuery)}`)
|
|
if (!res.ok) throw new Error("Search failed")
|
|
const data = await res.json() as { results: Array<{ id?: string; appId?: number; title: string; image: string | null; source: string }> }
|
|
if (!cancelled) {
|
|
setResults(
|
|
(data.results || [])
|
|
.filter((r) => !selectedGames.some(sg => sg.id === (r.id || `steam-${r.appId}`)))
|
|
.slice(0, 8)
|
|
.map((r) => ({
|
|
id: r.id || `steam-${r.appId}`,
|
|
appId: r.appId ?? null,
|
|
title: r.title,
|
|
image: r.image,
|
|
source: r.source,
|
|
}))
|
|
)
|
|
}
|
|
} catch {
|
|
if (!cancelled) setResults([])
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
}
|
|
fetchResults()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [debouncedQuery, selectedGames])
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick)
|
|
return () => document.removeEventListener("mousedown", handleClick)
|
|
}, [])
|
|
|
|
const canAdd = selectedGames.length < maxSelections
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3" ref={wrapperRef}>
|
|
{/* Selected games chips */}
|
|
{selectedGames.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedGames.map(game => (
|
|
<div
|
|
key={game.id}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/30 bg-primary/5 text-sm"
|
|
>
|
|
{game.image ? (
|
|
<Image src={game.image} alt={game.title} width={20} height={30} className="rounded" />
|
|
) : (
|
|
<Gamepad2Icon className="h-4 w-4 text-text/30" />
|
|
)}
|
|
<span className="text-text/80 max-w-40 truncate">{game.title}</span>
|
|
<button
|
|
onClick={() => onRemove(game.id)}
|
|
className="text-text/40 hover:text-text/80 transition-colors cursor-pointer"
|
|
>
|
|
<XIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search input */}
|
|
{canAdd && (
|
|
<div className="relative">
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-text/5">
|
|
<SearchIcon className="h-4 w-4 text-text/40" />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
|
onFocus={() => setOpen(true)}
|
|
placeholder="Search for games to compare..."
|
|
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text/40"
|
|
/>
|
|
</div>
|
|
|
|
{/* Dropdown results */}
|
|
{open && (debouncedQuery.length >= 2) && (
|
|
<div className="absolute z-50 top-full left-0 right-0 mt-1 rounded-lg border border-border bg-background shadow-lg max-h-64 overflow-y-auto">
|
|
{loading && (
|
|
<div className="px-4 py-3 text-xs text-text/40">Searching...</div>
|
|
)}
|
|
{!loading && results.length === 0 && (
|
|
<div className="px-4 py-3 text-xs text-text/40">No results found</div>
|
|
)}
|
|
{!loading && results.map(game => (
|
|
<button
|
|
key={game.id}
|
|
onClick={() => {
|
|
onSelect(game)
|
|
setQuery("")
|
|
setOpen(false)
|
|
}}
|
|
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-text/5 transition-colors cursor-pointer text-left"
|
|
>
|
|
{game.image ? (
|
|
<Image src={game.image} alt={game.title} width={24} height={36} className="rounded" />
|
|
) : (
|
|
<Gamepad2Icon className="h-4 w-4 text-text/30" />
|
|
)}
|
|
<span className="text-sm text-text/80 truncate">{game.title}</span>
|
|
{game.source !== "steam" && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 capitalize ml-auto">
|
|
{game.source}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!canAdd && (
|
|
<p className="text-xs text-text/40">Maximum {maxSelections} games can be compared</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|