169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import type { FavoriteGame } from "@/lib/integrations/types";
|
||
|
||
// Searchable favorite-games picker. Mirrors the console picker UX, but results
|
||
// come from the server (Steam store search via /games/search) since the catalog
|
||
// is far too large to bundle. Typing debounces a fetch; clicking a result adds
|
||
// it as a chip with its cover art. Free-text entry is always available for
|
||
// console/retro titles the search doesn't surface.
|
||
|
||
type Result = { name: string; image?: string; url?: string };
|
||
|
||
function Cover({ image }: { image?: string }) {
|
||
if (!image) return <span className="rb-pick-cover rb-pick-cover-empty" aria-hidden>🎲</span>;
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
return <img className="rb-pick-cover" src={image} alt="" loading="lazy" />;
|
||
}
|
||
|
||
export default function GamePicker({
|
||
name,
|
||
initial,
|
||
}: {
|
||
name: string;
|
||
initial: FavoriteGame[];
|
||
}) {
|
||
const [items, setItems] = useState<FavoriteGame[]>(initial);
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<Result[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// Search is event-driven (no effect): each keystroke reschedules a debounced
|
||
// fetch against the admin-only Steam search route, cancelling any in-flight
|
||
// request so only the latest query's results land.
|
||
function onQueryChange(next: string) {
|
||
setQuery(next);
|
||
setOpen(true);
|
||
if (timerRef.current) clearTimeout(timerRef.current);
|
||
const q = next.trim();
|
||
if (q.length < 2) {
|
||
abortRef.current?.abort();
|
||
setResults([]);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
timerRef.current = setTimeout(async () => {
|
||
abortRef.current?.abort();
|
||
const ctrl = new AbortController();
|
||
abortRef.current = ctrl;
|
||
try {
|
||
const res = await fetch(
|
||
`/admin/integrations/games/search?q=${encodeURIComponent(q)}`,
|
||
{ signal: ctrl.signal }
|
||
);
|
||
const json = (await res.json()) as { games?: Result[] };
|
||
setResults(json.games ?? []);
|
||
} catch {
|
||
// aborted or network error — leave prior results, drop the spinner
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function add(g: Result) {
|
||
setItems((prev) => [...prev, { name: g.name, image: g.image, url: g.url }]);
|
||
setQuery("");
|
||
setResults([]);
|
||
setOpen(false);
|
||
}
|
||
|
||
function addFreeform() {
|
||
const label = query.trim();
|
||
if (!label) return;
|
||
setItems((prev) => [...prev, { name: label }]);
|
||
setQuery("");
|
||
setResults([]);
|
||
setOpen(false);
|
||
}
|
||
|
||
function remove(idx: number) {
|
||
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||
}
|
||
|
||
function setNote(idx: number, note: string) {
|
||
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, note } : it)));
|
||
}
|
||
|
||
return (
|
||
<div className="rb-picker">
|
||
<input type="hidden" name={name} value={JSON.stringify(items)} />
|
||
|
||
<div className="rb-pick-search">
|
||
<input
|
||
type="text"
|
||
value={query}
|
||
placeholder="Search games — e.g. Chrono Trigger, Hollow Knight…"
|
||
autoComplete="off"
|
||
onChange={(e) => onQueryChange(e.target.value)}
|
||
onFocus={() => setOpen(true)}
|
||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (results[0]) add(results[0]);
|
||
else addFreeform();
|
||
}
|
||
}}
|
||
/>
|
||
{open && query.trim().length >= 2 && (
|
||
<ul className="rb-pick-suggest">
|
||
{loading && results.length === 0 && (
|
||
<li className="rb-pick-status">Searching…</li>
|
||
)}
|
||
{results.map((g, i) => (
|
||
<li key={`${g.url ?? g.name}-${i}`}>
|
||
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(g)}>
|
||
<Cover image={g.image} />
|
||
<span className="rb-pick-suggest-name">{g.name}</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
<li>
|
||
<button
|
||
type="button"
|
||
className="rb-pick-freeform"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={addFreeform}
|
||
>
|
||
+ Add “{query.trim()}” as custom
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{items.length > 0 && (
|
||
<ul className="rb-pick-list">
|
||
{items.map((it, i) => (
|
||
<li key={`${it.url ?? it.name}-${i}`} className="rb-pick-chip">
|
||
<Cover image={it.image} />
|
||
<span className="rb-pick-chip-name">{it.name}</span>
|
||
<input
|
||
className="rb-pick-chip-note"
|
||
type="text"
|
||
value={it.note ?? ""}
|
||
placeholder="note (optional)"
|
||
onChange={(e) => setNote(i, e.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="rb-pick-remove"
|
||
aria-label={`Remove ${it.name}`}
|
||
onClick={() => remove(i)}
|
||
>
|
||
✕
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|