Added easy drop-down to add consoles and games to bio

This commit is contained in:
2026-06-07 05:43:06 +02:00
parent 1f195a16de
commit c8ba511ebb
44 changed files with 2111 additions and 82 deletions
@@ -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>
);
}