Added easy drop-down to add consoles and games to bio
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user