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,153 @@
"use client";
import { useMemo, useState } from "react";
import {
searchConsoles,
isIconUrl,
type ConsoleDef,
} from "@/lib/integrations/consoles";
import type { ConsoleItem } from "@/lib/integrations/types";
// Searchable console picker. Filters the bundled registry locally (instant, no
// network), shows clickable suggestions, and keeps the chosen list as chips with
// an optional per-console note. The whole selection serializes to a single
// hidden input (JSON) so the surrounding server-action form picks it up as
// `name` with zero extra wiring.
function Glyph({ icon }: { icon?: string }) {
if (!icon) return <span className="rb-pick-glyph" aria-hidden>🎮</span>;
if (isIconUrl(icon))
// eslint-disable-next-line @next/next/no-img-element
return <img className="rb-pick-glyph-img" src={icon} alt="" />;
return (
<span className="rb-pick-glyph" aria-hidden>
{icon}
</span>
);
}
export default function ConsolePicker({
name,
initial,
}: {
name: string;
initial: ConsoleItem[];
}) {
const [items, setItems] = useState<ConsoleItem[]>(initial);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const chosenIds = useMemo(
() => new Set(items.map((i) => i.id).filter(Boolean) as string[]),
[items]
);
const suggestions = useMemo(
() => searchConsoles(query).filter((c) => !chosenIds.has(c.id)),
[query, chosenIds]
);
function add(def: ConsoleDef) {
setItems((prev) => [...prev, { id: def.id, name: def.name, icon: def.icon }]);
setQuery("");
setOpen(false);
}
function addFreeform() {
const label = query.trim();
if (!label) return;
setItems((prev) => [...prev, { name: label }]);
setQuery("");
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 consoles — e.g. Dreamcast, Sega, PS2…"
autoComplete="off"
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (suggestions[0]) add(suggestions[0]);
else addFreeform();
}
}}
/>
{open && (suggestions.length > 0 || query.trim()) && (
<ul className="rb-pick-suggest">
{suggestions.map((c) => (
<li key={c.id}>
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(c)}>
<Glyph icon={c.icon} />
<span className="rb-pick-suggest-name">{c.name}</span>
<span className="rb-pick-suggest-meta">
{c.maker}
{c.year ? ` · ${c.year}` : ""}
</span>
</button>
</li>
))}
{query.trim() && (
<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.id ?? it.name}-${i}`} className="rb-pick-chip">
<Glyph icon={it.icon} />
<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>
);
}
@@ -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>
);
}
@@ -0,0 +1,42 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
// Game search for the admin "favorite games" picker. Backed by Steam's public
// store-search endpoint: keyless, returns capsule cover art, and its catalog is
// broad enough to cover PC plus the countless console/retro titles that have
// since been re-released on Steam. The picker also allows free-text entry for
// anything the search can't find, so console-only games are never blocked.
//
// Admin-gated (it lives under the panel layout, but route handlers don't inherit
// that guard, so we check here too) to avoid turning the blog into an open proxy.
const STORE_SEARCH = "https://store.steampowered.com/api/storesearch/";
const TIMEOUT = 6000;
type StoreItem = { id: number; name: string; tiny_image?: string };
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const q = (new URL(req.url).searchParams.get("q") ?? "").trim();
if (q.length < 2) return NextResponse.json({ games: [] });
const url = `${STORE_SEARCH}?term=${encodeURIComponent(q)}&cc=us&l=en`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) {
return NextResponse.json({ games: [], error: `Steam ${res.status}` });
}
const json = (await res.json()) as { items?: StoreItem[] };
const games = (json.items ?? []).slice(0, 10).map((it) => ({
name: it.name,
image: it.tiny_image,
url: `https://store.steampowered.com/app/${it.id}`,
}));
return NextResponse.json({ games });
} catch {
return NextResponse.json({ games: [], error: "Search timed out." });
}
}
+232 -42
View File
@@ -1,23 +1,46 @@
import type { ReactNode } from "react";
import { getIntegrationConfig, getProfile } from "@/lib/integrations";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import type {
ConsoleItem,
FavoriteGame,
SocialLink,
} from "@/lib/integrations/types";
import type { SocialLink } from "@/lib/integrations/types";
import {
saveIntegrationsAction,
refreshIntegrationsAction,
testIntegrationAction,
} from "../../actions";
import ConsolePicker from "./ConsolePicker";
import GamePicker from "./GamePicker";
// Expandable "how do I get this?" help, shown under credential fields. Uses a
// native <details> so it works with zero JS and stays readable on mobile.
function CredHelp({ title, children }: { title: string; children: ReactNode }) {
return (
<details className="rb-cred-help">
<summary> {title}</summary>
<div className="rb-cred-help-body">{children}</div>
</details>
);
}
// A "Test connection" submit button. Submits the whole form to the test action
// (so it validates the live, unsaved field values) tagged with which platform.
function TestButton({ platform }: { platform: string }) {
return (
<button
type="submit"
className="rb-btn rb-btn-ghost"
formAction={testIntegrationAction.bind(null, platform)}
formNoValidate
>
Test connection
</button>
);
}
function socialToText(links: SocialLink[]): string {
return links
.map((l) => [l.network, l.url, l.label].filter(Boolean).join(" | "))
.join("\n");
}
function namedToText(items: (ConsoleItem | FavoriteGame)[]): string {
return items.map((i) => [i.name, i.note].filter(Boolean).join(" | ")).join("\n");
}
function fmtAge(iso: string): string {
const t = new Date(iso).getTime();
@@ -29,18 +52,51 @@ function fmtAge(iso: string): string {
return hrs < 24 ? `${hrs}h ago` : `${Math.round(hrs / 24)}d ago`;
}
function Banner({ saved, refreshed }: { saved?: string; refreshed?: string }) {
if (saved) return <p className="rb-admin-ok">Integrations saved. Caches cleared.</p>;
if (refreshed) return <p className="rb-admin-ok">Platform caches cleared data refetches on next view.</p>;
type Banners = {
saved?: string;
refreshed?: string;
steam?: string;
steamErr?: string;
test?: string;
testErr?: string;
testGames?: string;
testNotice?: string;
};
function Banner(p: Banners) {
if (p.saved) return <p className="rb-admin-ok">Integrations saved. Caches cleared.</p>;
if (p.refreshed)
return <p className="rb-admin-ok">Platform caches cleared data refetches on next view.</p>;
if (p.steam)
return (
<p className="rb-admin-ok">
Steam linked SteamID <code>{p.steam}</code> saved. Add your API key below to pull
game data.
</p>
);
if (p.steamErr) return <p className="rb-admin-error">{p.steamErr}</p>;
if (p.test && p.testErr)
return (
<p className="rb-admin-error">
{p.test.toUpperCase()} test failed: {p.testErr}
</p>
);
if (p.test)
return (
<p className={p.testNotice ? "rb-admin-warn" : "rb-admin-ok"}>
{p.test.toUpperCase()} connection OK fetched {p.testGames ?? 0} game(s).
{p.testNotice ? ` ${p.testNotice}` : ""}
</p>
);
return null;
}
export default async function IntegrationsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; refreshed?: string }>;
searchParams: Promise<Banners>;
}) {
const { saved, refreshed } = await searchParams;
const banners = await searchParams;
const cfg = getIntegrationConfig();
// Loads (cached) platform data so we can show freshness + any fetch errors.
const profile = await getProfile();
@@ -49,7 +105,7 @@ export default async function IntegrationsPage({
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Integrations</h1>
<Banner saved={saved} refreshed={refreshed} />
<Banner {...banners} />
<p className="rb-admin-muted">
Build your gamer bio: a profile, social links, console list, favorite
games, and live data pulled from gaming platforms. Everything here powers
@@ -136,31 +192,19 @@ export default async function IntegrationsPage({
{/* ---- consoles + favorites ---- */}
<h2 className="rb-admin-h2">Collection</h2>
<div className="rb-field-row">
<label className="rb-field">
<span>
Consoles owned <em className="rb-admin-muted">(name|note per line)</em>
</span>
<textarea
name="consoles"
rows={5}
className="rb-mono"
placeholder={"PlayStation 2|Phat, launch unit\nDreamcast|with VMU"}
defaultValue={namedToText(cfg.consoles)}
/>
</label>
<label className="rb-field">
<span>
Favorite games <em className="rb-admin-muted">(name|note per line)</em>
</span>
<textarea
name="favorites"
rows={5}
className="rb-mono"
placeholder={"Shadow of the Colossus|GOAT\nChrono Trigger"}
defaultValue={namedToText(cfg.favorites)}
/>
</label>
<div className="rb-field">
<span>
Consoles owned{" "}
<em className="rb-admin-muted"> search and click to add</em>
</span>
<ConsolePicker name="consoles" initial={cfg.consoles} />
</div>
<div className="rb-field">
<span>
Favorite games{" "}
<em className="rb-admin-muted"> search (Steam catalog) and click to add</em>
</span>
<GamePicker name="favorites" initial={cfg.favorites} />
</div>
{/* ---- platforms ---- */}
@@ -169,12 +213,20 @@ export default async function IntegrationsPage({
<input type="checkbox" name="steamEnabled" defaultChecked={cfg.steam.enabled} />
<span>Enable Steam integration</span>
</label>
<p className="rb-admin-muted">
One-click:{" "}
<a className="rb-btn rb-btn-ghost rb-btn-inline" href="/admin/integrations/steam/link">
Sign in with Steam
</a>{" "}
to fill your SteamID automatically{cfg.steam.steamId ? ` (current: ${cfg.steam.steamId})` : ""}.
You still add an API key below for game data.
</p>
<div className="rb-field-row">
<label className="rb-field">
<span>
API key{" "}
<em className="rb-admin-muted">
({cfg.steam.apiKey ? "stored — blank keeps it" : "from steamcommunity.com/dev"})
({cfg.steam.apiKey ? "stored — blank keeps it" : "required for game data"})
</em>
</span>
<input
@@ -183,12 +235,41 @@ export default async function IntegrationsPage({
autoComplete="off"
placeholder={cfg.steam.apiKey ? "•••••••• stored" : ""}
/>
<CredHelp title="How to get a Steam API key">
<ol>
<li>
Go to{" "}
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer">
steamcommunity.com/dev/apikey
</a>{" "}
(sign in if asked).
</li>
<li>Enter any domain name (e.g. <code>localhost</code>) and agree to the terms.</li>
<li>Copy the generated key and paste it here.</li>
</ol>
<p>Your Steam profile must be set to <strong>Public</strong> for game data to load.</p>
</CredHelp>
</label>
<label className="rb-field">
<span>SteamID (64-bit)</span>
<input name="steamId" defaultValue={cfg.steam.steamId} placeholder="7656119…" />
<CredHelp title="How to find your SteamID">
<ol>
<li>Use the Sign in with Steam button above easiest, fills it for you.</li>
<li>
Or open{" "}
<a href="https://steamid.io" target="_blank" rel="noreferrer">
steamid.io
</a>
, paste your profile URL, and copy the <code>steamID64</code> value.
</li>
</ol>
</CredHelp>
</label>
</div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="steam" />
</div>
<h2 className="rb-admin-h2">PlayStation (PSN)</h2>
<label className="rb-check">
@@ -199,7 +280,7 @@ export default async function IntegrationsPage({
<span>
NPSSO token{" "}
<em className="rb-admin-muted">
({cfg.psn.npsso ? "stored — blank keeps it" : "from ca.account.sony.com/api/v1/ssocookie"})
({cfg.psn.npsso ? "stored — blank keeps it" : "required"})
</em>
</span>
<input
@@ -208,7 +289,37 @@ export default async function IntegrationsPage({
autoComplete="off"
placeholder={cfg.psn.npsso ? "•••••••• stored" : "64-char token"}
/>
<CredHelp title="How to get your NPSSO token">
<ol>
<li>
Sign in at{" "}
<a href="https://www.playstation.com" target="_blank" rel="noreferrer">
playstation.com
</a>{" "}
in your browser.
</li>
<li>
In the same browser, open{" "}
<a
href="https://ca.account.sony.com/api/v1/ssocookie"
target="_blank"
rel="noreferrer"
>
ca.account.sony.com/api/v1/ssocookie
</a>
.
</li>
<li>
Copy the 64-character value of <code>npsso</code> from the JSON shown and paste it
here.
</li>
</ol>
<p>The token expires periodically re-paste a fresh one if PSN starts erroring.</p>
</CredHelp>
</label>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="psn" />
</div>
<h2 className="rb-admin-h2">Xbox</h2>
<label className="rb-check">
@@ -220,7 +331,7 @@ export default async function IntegrationsPage({
<span>
OpenXBL API key{" "}
<em className="rb-admin-muted">
({cfg.xbox.apiKey ? "stored — blank keeps it" : "from xbl.io"})
({cfg.xbox.apiKey ? "stored — blank keeps it" : "required"})
</em>
</span>
<input
@@ -229,14 +340,93 @@ export default async function IntegrationsPage({
autoComplete="off"
placeholder={cfg.xbox.apiKey ? "•••••••• stored" : ""}
/>
<CredHelp title="How to get an OpenXBL API key">
<ol>
<li>
Go to{" "}
<a href="https://xbl.io" target="_blank" rel="noreferrer">
xbl.io
</a>{" "}
and sign in with your Microsoft / Xbox account.
</li>
<li>Open the console / API keys page and generate a key.</li>
<li>Copy the key and paste it here.</li>
</ol>
<p>Xbox has no official public API; OpenXBL is a third-party proxy.</p>
</CredHelp>
</label>
<label className="rb-field">
<span>
XUID <em className="rb-admin-muted">(optional)</em>
</span>
<input name="xboxXuid" defaultValue={cfg.xbox.xuid} />
<CredHelp title="Do I need a XUID?">
<p>
No leave blank and OpenXBL uses the account tied to your API key. Only set this to
read a <em>different</em> profile.
</p>
</CredHelp>
</label>
</div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="xbox" />
</div>
<h2 className="rb-admin-h2">RetroAchievements</h2>
<label className="rb-check">
<input type="checkbox" name="retroEnabled" defaultChecked={cfg.retro.enabled} />
<span>Enable RetroAchievements integration</span>
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>Username</span>
<input
name="retroUsername"
defaultValue={cfg.retro.username}
placeholder="your RA username"
/>
</label>
<label className="rb-field">
<span>
Web API key{" "}
<em className="rb-admin-muted">
({cfg.retro.apiKey ? "stored — blank keeps it" : "required"})
</em>
</span>
<input
name="retroApiKey"
type="password"
autoComplete="off"
placeholder={cfg.retro.apiKey ? "•••••••• stored" : ""}
/>
<CredHelp title="How to get a RetroAchievements API key">
<ol>
<li>
Sign in at{" "}
<a href="https://retroachievements.org" target="_blank" rel="noreferrer">
retroachievements.org
</a>
.
</li>
<li>
Open{" "}
<a
href="https://retroachievements.org/settings"
target="_blank"
rel="noreferrer"
>
Settings Keys
</a>{" "}
and copy your <strong>Web API Key</strong>.
</li>
<li>Paste it here along with your username above.</li>
</ol>
</CredHelp>
</label>
</div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="retro" />
</div>
{/* ---- cache ---- */}
<h2 className="rb-admin-h2">Caching</h2>
@@ -0,0 +1,27 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
// Step 1 of Steam's one-click sign-in (OpenID 2.0). We bounce the admin to
// Steam, which authenticates them and redirects back to /return with their
// identity. No API key or secret is needed for this exchange — that's what makes
// it one-click: the admin just confirms on Steam and their SteamID flows back.
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.redirect(new URL("/admin/login", req.url));
}
const origin = new URL(req.url).origin;
const params = new URLSearchParams({
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": `${origin}/admin/integrations/steam/return`,
"openid.realm": origin,
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
});
return NextResponse.redirect(`${STEAM_OPENID}?${params}`);
}
@@ -0,0 +1,53 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
import { getIntegrationConfig, saveIntegrationConfig } from "@/lib/integrations";
// Step 2 of Steam OpenID sign-in. Steam redirects back here with its assertion.
// We MUST verify it by echoing the params back to Steam with mode=check_auth;
// trusting the query string blindly would let anyone forge a SteamID. On success
// we extract the 64-bit id from the claimed_id URL and persist it. The Steam API
// key (needed to actually read game data) is still entered separately.
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
const CLAIMED_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/;
function back(req: NextRequest, qs: Record<string, string>): NextResponse {
const url = new URL("/admin/integrations", req.url);
for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, v);
return NextResponse.redirect(url);
}
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.redirect(new URL("/admin/login", req.url));
}
const incoming = new URL(req.url).searchParams;
const claimed = incoming.get("openid.claimed_id") ?? "";
const match = CLAIMED_RE.exec(claimed);
if (!match) return back(req, { steamErr: "Steam sign-in returned no identity." });
// Re-send every openid.* param back to Steam with mode=check_authentication.
const verify = new URLSearchParams();
for (const [k, v] of incoming) verify.set(k, v);
verify.set("openid.mode", "check_authentication");
let valid = false;
try {
const res = await fetch(STEAM_OPENID, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: verify,
signal: AbortSignal.timeout(8000),
});
valid = (await res.text()).includes("is_valid:true");
} catch {
return back(req, { steamErr: "Could not reach Steam to verify sign-in." });
}
if (!valid) return back(req, { steamErr: "Steam sign-in could not be verified." });
const steamId = match[1];
const cfg = getIntegrationConfig();
saveIntegrationConfig({ ...cfg, steam: { ...cfg.steam, enabled: true, steamId } });
return back(req, { steam: steamId });
}
+73 -16
View File
@@ -25,12 +25,14 @@ import {
getIntegrationConfig,
saveIntegrationConfig,
refreshPlatforms,
testPlatform,
} from "@/lib/integrations";
import { isSocialNetworkId, type SocialNetworkId } from "@/lib/integrations/social";
import type {
ConsoleItem,
FavoriteGame,
IntegrationConfig,
PlatformId,
SocialLink,
} from "@/lib/integrations/types";
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
@@ -211,31 +213,57 @@ function parseSocialLines(raw: string): SocialLink[] {
return out;
}
// Each line: `name|note?`.
function parseNamedLines(raw: string): { name: string; note?: string }[] {
const out: { name: string; note?: string }[] = [];
for (const line of raw.split("\n")) {
const [name, ...rest] = line.split("|").map((p) => p.trim());
if (!name) continue;
const note = rest.join("|").trim();
out.push({ name, note: note || undefined });
// The console / game pickers post their selection as a JSON array (one hidden
// input each). Parse defensively — a malformed payload yields an empty list
// rather than throwing. config.normalize* re-validates field shapes after this.
function parseJsonArray(raw: string): Record<string, unknown>[] {
if (!raw.trim()) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
} catch {
return [];
}
return out;
}
export async function saveIntegrationsAction(formData: FormData) {
const current = getIntegrationConfig();
function parseConsolesJson(raw: string): ConsoleItem[] {
return parseJsonArray(raw)
.map((o) => ({
id: typeof o.id === "string" ? o.id : undefined,
name: typeof o.name === "string" ? o.name.trim() : "",
icon: typeof o.icon === "string" ? o.icon : undefined,
note: typeof o.note === "string" ? o.note.trim() || undefined : undefined,
}))
.filter((c) => c.name);
}
const next: IntegrationConfig = {
function parseFavoritesJson(raw: string): FavoriteGame[] {
return parseJsonArray(raw)
.map((o) => ({
name: typeof o.name === "string" ? o.name.trim() : "",
image: typeof o.image === "string" ? o.image : undefined,
url: typeof o.url === "string" ? o.url : undefined,
note: typeof o.note === "string" ? o.note.trim() || undefined : undefined,
}))
.filter((f) => f.name);
}
// Build a full IntegrationConfig from the admin form. Blank credential fields
// fall back to the stored secret so saving never wipes a key the user left
// masked. Shared by save + test so both see identical (live) values.
function configFromForm(
formData: FormData,
current: IntegrationConfig
): IntegrationConfig {
return {
displayName: s(formData, "displayName"),
bio: s(formData, "bio"),
avatarUrl: s(formData, "avatarUrl"),
social: parseSocialLines(s(formData, "social")),
consoles: parseNamedLines(s(formData, "consoles")) as ConsoleItem[],
favorites: parseNamedLines(s(formData, "favorites")) as FavoriteGame[],
consoles: parseConsolesJson(s(formData, "consoles")),
favorites: parseFavoritesJson(s(formData, "favorites")),
steam: {
enabled: formData.get("steamEnabled") === "on",
// Blank credential field = keep the stored one (so secrets aren't wiped on save).
apiKey: s(formData, "steamApiKey").trim() || current.steam.apiKey,
steamId: s(formData, "steamId").trim(),
},
@@ -248,16 +276,45 @@ export async function saveIntegrationsAction(formData: FormData) {
apiKey: s(formData, "xboxApiKey").trim() || current.xbox.apiKey,
xuid: s(formData, "xboxXuid").trim(),
},
retro: {
enabled: formData.get("retroEnabled") === "on",
username: s(formData, "retroUsername").trim(),
apiKey: s(formData, "retroApiKey").trim() || current.retro.apiKey,
},
cacheTtlMinutes: Number(s(formData, "cacheTtlMinutes")) || current.cacheTtlMinutes,
};
}
saveIntegrationConfig(next);
export async function saveIntegrationsAction(formData: FormData) {
const current = getIntegrationConfig();
saveIntegrationConfig(configFromForm(formData, current));
// Config changed (creds/toggles) — drop cached payloads so they refetch.
refreshPlatforms();
revalidateSite();
redirect("/admin/integrations?saved=1");
}
const PLATFORM_IDS: PlatformId[] = ["steam", "psn", "xbox", "retro"];
function isPlatformId(v: string): v is PlatformId {
return (PLATFORM_IDS as string[]).includes(v);
}
// "Test connection": runs the chosen platform's fetcher against the live form
// values (not the saved config) so the admin can validate creds before saving.
export async function testIntegrationAction(platform: string, formData: FormData) {
if (!isPlatformId(platform)) redirect("/admin/integrations");
const cfg = configFromForm(formData, getIntegrationConfig());
const result = await testPlatform(platform, cfg);
const qs = new URLSearchParams({ test: platform });
if (result.error) qs.set("testErr", result.error);
else {
qs.set("testGames", String(result.games.length));
if (result.notice) qs.set("testNotice", result.notice);
}
redirect(`/admin/integrations?${qs}`);
}
export async function refreshIntegrationsAction() {
refreshPlatforms();
revalidateSite();
+215
View File
@@ -169,6 +169,7 @@
/* ---- banners ---- */
.rb-admin-ok,
.rb-admin-warn,
.rb-admin-error {
border-radius: var(--a-radius);
padding: 10px 14px;
@@ -181,6 +182,11 @@
color: var(--a-ok-text);
}
.rb-admin-warn {
background: color-mix(in srgb, #f5a623 22%, transparent);
color: #b8740f;
}
.rb-admin-error {
background: var(--a-err-bg);
color: var(--a-err-text);
@@ -410,3 +416,212 @@
color: #555;
border-bottom: 2px solid #d0d0d0;
}
/* integrations: per-credential expandable help (tooltips) */
.rb-cred-help {
margin-top: 6px;
font-size: 13px;
}
.rb-cred-help > summary {
cursor: pointer;
color: var(--a-muted);
user-select: none;
list-style: none;
}
.rb-cred-help > summary:hover {
color: var(--a-text);
text-decoration: underline;
}
.rb-cred-help[open] > summary {
color: var(--a-text);
font-weight: 500;
}
.rb-cred-help-body {
margin-top: 6px;
padding: 10px 12px;
background: #f6f8fa;
border: 1px solid var(--a-border);
border-radius: 6px;
color: var(--a-text);
}
.rb-cred-help-body ol {
margin: 0;
padding-left: 18px;
}
.rb-cred-help-body li {
margin: 2px 0;
}
.rb-cred-help-body p {
margin: 8px 0 0;
color: var(--a-muted);
}
/* integrations: per-platform action row (e.g. Test connection) */
.rb-form-actions-sub {
margin-top: 10px;
margin-bottom: 6px;
}
.rb-btn-inline {
padding: 4px 10px;
font-size: 13px;
border-color: var(--a-border);
background: #fff;
}
/* integrations: console / game pickers */
.rb-picker {
margin-bottom: 16px;
}
.rb-pick-search {
position: relative;
}
.rb-pick-search > input {
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 9px 11px;
width: 100%;
}
.rb-pick-search > input:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
border-color: var(--a-primary);
}
.rb-pick-suggest {
position: absolute;
z-index: 20;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
background: var(--a-surface);
border: 1px solid var(--a-border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 320px;
overflow: auto;
}
.rb-pick-suggest > li > button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
font: inherit;
color: var(--a-text);
background: transparent;
border: 0;
border-radius: 4px;
padding: 7px 9px;
cursor: pointer;
}
.rb-pick-suggest > li > button:hover {
background: #eef1f6;
}
.rb-pick-status {
padding: 8px 9px;
color: var(--a-muted);
}
.rb-pick-suggest-name {
font-weight: 500;
flex: 1;
}
.rb-pick-suggest-meta {
color: var(--a-muted);
font-size: 12px;
white-space: nowrap;
}
.rb-pick-freeform {
color: var(--a-primary) !important;
font-weight: 500;
}
.rb-pick-glyph,
.rb-pick-glyph-img,
.rb-pick-cover {
flex: none;
}
.rb-pick-glyph {
font-size: 18px;
width: 28px;
text-align: center;
}
.rb-pick-glyph-img {
height: 20px;
width: auto;
max-width: 90px;
object-fit: contain;
}
.rb-pick-cover {
width: 46px;
height: 22px;
object-fit: cover;
border: 1px solid var(--a-border);
}
.rb-pick-cover-empty {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 22px;
background: #eef1f6;
border: 1px solid var(--a-border);
font-size: 13px;
}
.rb-pick-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.rb-pick-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid var(--a-border);
border-radius: 6px;
background: #fbfcfe;
}
.rb-pick-chip-name {
font-weight: 500;
min-width: 0;
flex: none;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-pick-chip-note {
flex: 1;
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 5px;
padding: 5px 8px;
}
.rb-pick-chip-note:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
}
.rb-pick-remove {
flex: none;
font: inherit;
line-height: 1;
color: var(--a-muted);
background: transparent;
border: 0;
border-radius: 4px;
padding: 4px 7px;
cursor: pointer;
}
.rb-pick-remove:hover {
background: var(--a-err-bg);
color: var(--a-danger);
}
+42 -7
View File
@@ -3,10 +3,17 @@ import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import { getProfile, hasIntegrations } from "@/lib/integrations";
import { isIconUrl } from "@/lib/integrations/consoles";
import type { Achievement, Game } from "@/lib/integrations/types";
function platformLabel(p: string): string {
return p === "psn" ? "PlayStation" : p === "xbox" ? "Xbox" : "Steam";
const labels: Record<string, string> = {
psn: "PlayStation",
xbox: "Xbox",
retro: "RetroAchievements",
steam: "Steam",
};
return labels[p] ?? "Steam";
}
function fmtPlaytime(mins?: number): string | null {
@@ -123,12 +130,31 @@ export default async function BioPage() {
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Favorite games</h2>
<ul className="rb-fav-list">
{profile.favorites.map((f, i) => (
<li key={i} className="rb-fav">
<span className="rb-fav-name">{f.name}</span>
{f.note && <span className="rb-fav-note">{f.note}</span>}
</li>
))}
{profile.favorites.map((f, i) => {
const body = (
<>
{f.image && (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-fav-cover" src={f.image} alt="" loading="lazy" />
)}
<span className="rb-fav-text">
<span className="rb-fav-name">{f.name}</span>
{f.note && <span className="rb-fav-note">{f.note}</span>}
</span>
</>
);
return (
<li key={i} className="rb-fav">
{f.url ? (
<a href={f.url} target="_blank" rel="noreferrer" className="rb-fav-link">
{body}
</a>
) : (
body
)}
</li>
);
})}
</ul>
</section>
)}
@@ -140,6 +166,15 @@ export default async function BioPage() {
<ul className="rb-console-list">
{profile.consoles.map((c, i) => (
<li key={i} className="rb-console">
{c.icon &&
(isIconUrl(c.icon) ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-console-icon-img" src={c.icon} alt="" loading="lazy" />
) : (
<span className="rb-console-icon" aria-hidden>
{c.icon}
</span>
))}
<span className="rb-console-name">{c.name}</span>
{c.note && <span className="rb-console-note">{c.note}</span>}
</li>
+34 -2
View File
@@ -399,12 +399,44 @@ img {
.rb-fav,
.rb-console {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-left: 3px solid currentColor;
background: rgba(0, 0, 0, 0.04);
}
.rb-fav-link {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
width: 100%;
}
.rb-fav-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.rb-fav-cover {
width: 56px;
height: 26px;
object-fit: cover;
flex: none;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.rb-console-icon {
font-size: 1.2em;
line-height: 1;
flex: none;
}
.rb-console-icon-img {
height: 20px;
width: auto;
max-width: 96px;
object-fit: contain;
flex: none;
}
.rb-fav-name,
.rb-console-name {
font-weight: bold;
+19 -2
View File
@@ -22,6 +22,7 @@ export const DEFAULT_CONFIG: IntegrationConfig = {
steam: { enabled: false, apiKey: "", steamId: "" },
psn: { enabled: false, npsso: "" },
xbox: { enabled: false, apiKey: "", xuid: "" },
retro: { enabled: false, username: "", apiKey: "" },
cacheTtlMinutes: 360,
};
@@ -54,7 +55,12 @@ function normConsoles(input: unknown): ConsoleItem[] {
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
return {
id: str(o.id).trim() || undefined,
name: str(o.name).trim(),
icon: str(o.icon).trim() || undefined,
note: str(o.note).trim() || undefined,
};
})
.filter((c) => c.name);
}
@@ -64,7 +70,12 @@ function normFavorites(input: unknown): FavoriteGame[] {
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
return {
name: str(o.name).trim(),
image: str(o.image).trim() || undefined,
url: str(o.url).trim() || undefined,
note: str(o.note).trim() || undefined,
};
})
.filter((f) => f.name);
}
@@ -74,6 +85,7 @@ export function normalizeConfig(input: unknown): IntegrationConfig {
const steam = (o.steam ?? {}) as Record<string, unknown>;
const psn = (o.psn ?? {}) as Record<string, unknown>;
const xbox = (o.xbox ?? {}) as Record<string, unknown>;
const retro = (o.retro ?? {}) as Record<string, unknown>;
const ttl = Number(o.cacheTtlMinutes);
return {
displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
@@ -96,6 +108,11 @@ export function normalizeConfig(input: unknown): IntegrationConfig {
apiKey: str(xbox.apiKey).trim(),
xuid: str(xbox.xuid).trim(),
},
retro: {
enabled: bool(retro.enabled),
username: str(retro.username).trim(),
apiKey: str(retro.apiKey).trim(),
},
cacheTtlMinutes:
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
};
+134
View File
@@ -0,0 +1,134 @@
// Registry of known gaming consoles / platforms. Powers the admin "console
// picker": a searchable list the author clicks to build their owned-hardware
// shelf. Kept free of any server-only imports so the picker (a client
// component) can bundle and filter it locally for instant suggestions — same
// philosophy as ./social.
//
// `icon`, when present, is a path to a self-hosted brand logo SVG under
// /public/consoles (sourced from Wikimedia Commons / Simple Icons). Consoles
// without a logo omit `icon` and render as plain text. Where a model has no
// logo of its own, it falls back to its maker's mark (e.g. all Sega models use
// the Sega logo). Renderers <img> any icon that looks like a path/URL.
export type ConsoleDef = {
/** Stable slug, persisted on the saved item. */
id: string;
name: string;
/** Maker — shown as a muted hint + used to weight search. */
maker: string;
/** Release year, for disambiguation in suggestions. */
year?: number;
/** Logo path (/consoles/*.svg) or URL. Absent → text-only. */
icon?: string;
};
// Hand-curated, roughly chronological within each maker. Not exhaustive, but
// covers the consoles a retro-leaning author is likely to list.
export const CONSOLES: ConsoleDef[] = [
// --- Nintendo ---
{ id: "nes", name: "NES", maker: "Nintendo", year: 1983, icon: "/consoles/nes.svg" },
{ id: "fds", name: "Famicom Disk System", maker: "Nintendo", year: 1986 },
{ id: "snes", name: "Super Nintendo (SNES)", maker: "Nintendo", year: 1990, icon: "/consoles/snes.svg" },
{ id: "n64", name: "Nintendo 64", maker: "Nintendo", year: 1996 },
{ id: "gamecube", name: "GameCube", maker: "Nintendo", year: 2001 },
{ id: "wii", name: "Wii", maker: "Nintendo", year: 2006, icon: "/consoles/wii.svg" },
{ id: "wiiu", name: "Wii U", maker: "Nintendo", year: 2012, icon: "/consoles/wiiu.svg" },
{ id: "switch", name: "Nintendo Switch", maker: "Nintendo", year: 2017, icon: "/consoles/switch.svg" },
{ id: "gameboy", name: "Game Boy", maker: "Nintendo", year: 1989, icon: "/consoles/gameboy.svg" },
{ id: "gbc", name: "Game Boy Color", maker: "Nintendo", year: 1998, icon: "/consoles/gbc.svg" },
{ id: "gba", name: "Game Boy Advance", maker: "Nintendo", year: 2001, icon: "/consoles/gba.svg" },
{ id: "nds", name: "Nintendo DS", maker: "Nintendo", year: 2004, icon: "/consoles/nds.svg" },
{ id: "3ds", name: "Nintendo 3DS", maker: "Nintendo", year: 2011, icon: "/consoles/3ds.svg" },
{ id: "virtualboy", name: "Virtual Boy", maker: "Nintendo", year: 1995 },
// --- Sega (models without their own logo fall back to the Sega mark) ---
{ id: "sg1000", name: "SG-1000", maker: "Sega", year: 1983, icon: "/consoles/sega.svg" },
{ id: "mastersystem", name: "Master System", maker: "Sega", year: 1985, icon: "/consoles/sega.svg" },
{ id: "genesis", name: "Genesis / Mega Drive", maker: "Sega", year: 1988, icon: "/consoles/sega.svg" },
{ id: "segacd", name: "Sega CD", maker: "Sega", year: 1991, icon: "/consoles/sega.svg" },
{ id: "saturn", name: "Sega Saturn", maker: "Sega", year: 1994, icon: "/consoles/sega.svg" },
{ id: "dreamcast", name: "Dreamcast", maker: "Sega", year: 1998, icon: "/consoles/dreamcast.svg" },
{ id: "gamegear", name: "Game Gear", maker: "Sega", year: 1990, icon: "/consoles/sega.svg" },
// --- Sony ---
{ id: "ps1", name: "PlayStation", maker: "Sony", year: 1994, icon: "/consoles/ps1.svg" },
{ id: "ps2", name: "PlayStation 2", maker: "Sony", year: 2000, icon: "/consoles/ps2.svg" },
{ id: "ps3", name: "PlayStation 3", maker: "Sony", year: 2006, icon: "/consoles/ps3.svg" },
{ id: "ps4", name: "PlayStation 4", maker: "Sony", year: 2013, icon: "/consoles/ps4.svg" },
{ id: "ps5", name: "PlayStation 5", maker: "Sony", year: 2020, icon: "/consoles/ps5.svg" },
{ id: "psp", name: "PSP", maker: "Sony", year: 2004, icon: "/consoles/psp.svg" },
{ id: "psvita", name: "PS Vita", maker: "Sony", year: 2011, icon: "/consoles/psvita.svg" },
// --- Microsoft (360 / Series fall back to the Xbox mark) ---
{ id: "xbox", name: "Xbox", maker: "Microsoft", year: 2001, icon: "/consoles/xbox.svg" },
{ id: "xbox360", name: "Xbox 360", maker: "Microsoft", year: 2005, icon: "/consoles/xbox.svg" },
{ id: "xboxone", name: "Xbox One", maker: "Microsoft", year: 2013, icon: "/consoles/xboxone.svg" },
{ id: "xboxseries", name: "Xbox Series X|S", maker: "Microsoft", year: 2020, icon: "/consoles/xbox.svg" },
// --- Atari (all fall back to the Atari mark) ---
{ id: "atari2600", name: "Atari 2600", maker: "Atari", year: 1977, icon: "/consoles/atari2600.svg" },
{ id: "atari5200", name: "Atari 5200", maker: "Atari", year: 1982, icon: "/consoles/atari2600.svg" },
{ id: "atari7800", name: "Atari 7800", maker: "Atari", year: 1986, icon: "/consoles/atari2600.svg" },
{ id: "lynx", name: "Atari Lynx", maker: "Atari", year: 1989, icon: "/consoles/atari2600.svg" },
{ id: "jaguar", name: "Atari Jaguar", maker: "Atari", year: 1993, icon: "/consoles/atari2600.svg" },
// --- Other consoles / handhelds ---
{ id: "neogeo", name: "Neo Geo (AES)", maker: "SNK", year: 1990 },
{ id: "ngpc", name: "Neo Geo Pocket Color", maker: "SNK", year: 1999 },
{ id: "turbografx16", name: "TurboGrafx-16 / PC Engine", maker: "NEC", year: 1987 },
{ id: "3do", name: "3DO", maker: "Panasonic", year: 1993 },
{ id: "colecovision", name: "ColecoVision", maker: "Coleco", year: 1982 },
{ id: "intellivision", name: "Intellivision", maker: "Mattel", year: 1979 },
{ id: "wonderswan", name: "WonderSwan", maker: "Bandai", year: 1999 },
{ id: "steamdeck", name: "Steam Deck", maker: "Valve", year: 2022, icon: "/consoles/steamdeck.svg" },
{ id: "ouya", name: "Ouya", maker: "Ouya", year: 2013 },
// --- Computers ---
{ id: "pc", name: "PC", maker: "Microsoft Windows" },
{ id: "mac", name: "Mac", maker: "Apple", icon: "/consoles/mac.svg" },
{ id: "linux", name: "Linux", maker: "GNU/Linux" },
{ id: "c64", name: "Commodore 64", maker: "Commodore", year: 1982, icon: "/consoles/commodore.svg" },
{ id: "amiga", name: "Amiga", maker: "Commodore", year: 1985, icon: "/consoles/commodore.svg" },
{ id: "msx", name: "MSX", maker: "Microsoft / ASCII", year: 1983 },
{ id: "zxspectrum", name: "ZX Spectrum", maker: "Sinclair", year: 1982 },
{ id: "dos", name: "MS-DOS", maker: "Microsoft", year: 1981 },
{ id: "arcade", name: "Arcade", maker: "Various" },
];
const BY_ID = new Map(CONSOLES.map((c) => [c.id, c]));
export function isConsoleId(v: unknown): v is string {
return typeof v === "string" && BY_ID.has(v);
}
export function consoleById(id: string): ConsoleDef | undefined {
return BY_ID.get(id);
}
/** True when an icon string should render as an <img> rather than text. */
export function isIconUrl(icon: string): boolean {
return /^(https?:\/\/|\/)/.test(icon);
}
/**
* Filter the registry for the picker. Matches name + maker, ranks prefix hits
* first, and caps results so the suggestion list stays tight.
*/
export function searchConsoles(query: string, limit = 8): ConsoleDef[] {
const q = query.trim().toLowerCase();
if (!q) return CONSOLES.slice(0, limit);
const scored: { c: ConsoleDef; score: number }[] = [];
for (const c of CONSOLES) {
const name = c.name.toLowerCase();
const maker = c.maker.toLowerCase();
let score = -1;
if (name.startsWith(q)) score = 3;
else if (name.includes(q)) score = 2;
else if (maker.includes(q)) score = 1;
if (score >= 0) scored.push({ c, score });
}
return scored
.sort((a, b) => b.score - a.score || a.c.name.localeCompare(b.c.name))
.slice(0, limit)
.map((s) => s.c);
}
+37
View File
@@ -5,6 +5,7 @@ import { getIntegrationConfig } from "./config";
import { fetchSteam } from "./steam";
import { fetchPsn } from "./psn";
import { fetchXbox } from "./xbox";
import { fetchRetro } from "./retro";
import type {
Game,
IntegrationConfig,
@@ -20,6 +21,7 @@ const CACHE_KEYS: Record<PlatformId, string> = {
steam: "platform:steam",
psn: "platform:psn",
xbox: "platform:xbox",
retro: "platform:retro",
};
function emptyPlatform(platform: PlatformId): PlatformData {
@@ -45,6 +47,9 @@ async function loadPlatform(
case "xbox":
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
break;
case "retro":
result = await cached(key, ttl, fallback, () => fetchRetro(cfg.retro));
break;
}
// Surface a cache-layer error onto the payload if the fetch's own error is unset.
return result.error && !result.data.error
@@ -57,6 +62,7 @@ function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
if (cfg.steam.enabled) out.push("steam");
if (cfg.psn.enabled) out.push("psn");
if (cfg.xbox.enabled) out.push("xbox");
if (cfg.retro.enabled) out.push("retro");
return out;
}
@@ -125,3 +131,34 @@ export function hasIntegrations(): boolean {
export function refreshPlatforms(): void {
clearCache(Object.values(CACHE_KEYS));
}
/**
* Run one platform's fetcher directly (no cache) against an arbitrary config —
* powers the admin "Test connection" buttons, which validate live form values
* before anything is saved. Returns the payload (with `.error` set on failure).
*/
export async function testPlatform(
platform: PlatformId,
cfg: IntegrationConfig
): Promise<PlatformData> {
try {
switch (platform) {
case "steam":
return await fetchSteam({ ...cfg.steam, enabled: true });
case "psn":
return await fetchPsn({ ...cfg.psn, enabled: true });
case "xbox":
return await fetchXbox({ ...cfg.xbox, enabled: true });
case "retro":
return await fetchRetro({ ...cfg.retro, enabled: true });
}
} catch (e) {
return {
platform,
games: [],
achievements: [],
error: e instanceof Error ? e.message : String(e),
fetchedAt: new Date().toISOString(),
};
}
}
+90
View File
@@ -0,0 +1,90 @@
import "server-only";
import type { Achievement, Game, PlatformData, RetroConfig } from "./types";
// RetroAchievements has a clean, official, key-based Web API. We pull the user's
// recently-played games and their recent achievement unlocks. Auth is two query
// params on every call: z = username, y = web API key.
// Docs: https://api-docs.retroachievements.org
const API = "https://retroachievements.org/API";
const MEDIA = "https://media.retroachievements.org";
const TIMEOUT = 8000;
function media(path?: string): string | undefined {
if (!path) return undefined;
return path.startsWith("http") ? path : `${MEDIA}${path}`;
}
async function getJson(path: string, cfg: RetroConfig): Promise<unknown> {
const auth = `z=${encodeURIComponent(cfg.username)}&y=${encodeURIComponent(cfg.apiKey)}`;
const url = `${API}/${path}${path.includes("?") ? "&" : "?"}${auth}`;
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) throw new Error(`RetroAchievements ${res.status} ${res.statusText}`);
return res.json();
}
type RecentGame = {
GameID: number;
Title: string;
ImageIcon?: string;
ConsoleName?: string;
LastPlayed?: string;
NumAchieved?: number;
};
type RecentAch = {
AchievementID: number;
Title: string;
Description?: string;
BadgeName?: string;
GameTitle?: string;
ConsoleName?: string;
Date?: string;
};
export async function fetchRetro(cfg: RetroConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "retro",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.username || !cfg.apiKey) {
return { ...base, error: "RetroAchievements username and API key are required." };
}
const recent = (await getJson(
`API_GetUserRecentlyPlayedGames.php?u=${encodeURIComponent(cfg.username)}&c=12`,
cfg
)) as RecentGame[];
const games: Game[] = (recent ?? []).map((g) => ({
platform: "retro",
name: g.ConsoleName ? `${g.Title} (${g.ConsoleName})` : g.Title,
image: media(g.ImageIcon),
url: `https://retroachievements.org/game/${g.GameID}`,
lastPlayed: g.LastPlayed ? g.LastPlayed.replace(" ", "T") + "Z" : undefined,
}));
// Recent achievement unlocks across the last ~2 weeks (m = minutes back).
let achievements: Achievement[] = [];
try {
const ach = (await getJson(
`API_GetUserRecentAchievements.php?u=${encodeURIComponent(cfg.username)}&m=20160`,
cfg
)) as RecentAch[];
achievements = (ach ?? []).slice(0, 8).map((a) => ({
platform: "retro" as const,
game: a.GameTitle ?? "RetroAchievements",
name: a.Title,
description: a.Description,
icon: a.BadgeName ? `${MEDIA}/Badge/${a.BadgeName}.png` : undefined,
unlockedAt: a.Date ? a.Date.replace(" ", "T") + "Z" : undefined,
}));
} catch {
/* achievements optional; keep the games */
}
return { ...base, games, achievements };
}
+2
View File
@@ -17,6 +17,7 @@ export type SocialNetworkId =
| "steam"
| "psn"
| "xbox"
| "retroachievements"
| "rss"
| "website";
@@ -42,6 +43,7 @@ export const SOCIAL_NETWORKS: SocialNetwork[] = [
{ id: "steam", name: "Steam", icon: "🕹️" },
{ id: "psn", name: "PlayStation", icon: "🎯" },
{ id: "xbox", name: "Xbox", icon: "🟢" },
{ id: "retroachievements", name: "RetroAchievements", icon: "🏆" },
{ id: "rss", name: "RSS", icon: "📡" },
{ id: "website", name: "Website", icon: "🌐" },
];
+49 -12
View File
@@ -4,13 +4,18 @@ import type { Achievement, Game, PlatformData, SteamConfig } from "./types";
// Steam Web API. The only platform here with a sane, official, key-based API.
// Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API
//
// We pull the recently-played games, then fetch unlocked achievements (with
// We pull the recently-played games (last 2 weeks). If the account hasn't been
// played recently that list is empty, so we fall back to the full owned-games
// library sorted by last-played. We then fetch unlocked achievements (with
// human names from the game schema) for the single most-recent title to keep
// the request count bounded.
const API = "https://api.steampowered.com";
const TIMEOUT = 8000;
// Thrown when the Steam account's "Game details" privacy hides achievement data.
class PrivacyError extends Error {}
function header(appid: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
}
@@ -34,6 +39,20 @@ async function recentGames(key: string, steamId: string): Promise<RecentGame[]>
return json.response?.games ?? [];
}
type OwnedGame = RecentGame & { rtime_last_played?: number };
// Fallback when nothing was played in the last 2 weeks: the whole library,
// sorted by most-recently-played, trimmed to the same count as the recent list.
async function ownedGames(key: string, steamId: string): Promise<RecentGame[]> {
const url = `${API}/IPlayerService/GetOwnedGames/v1/?key=${key}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json`;
const json = (await getJson(url)) as { response?: { games?: OwnedGame[] } };
const games = json.response?.games ?? [];
return games
.slice()
.sort((a, b) => (b.rtime_last_played ?? 0) - (a.rtime_last_played ?? 0))
.slice(0, 12);
}
type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string };
async function achievementsFor(
@@ -47,10 +66,16 @@ async function achievementsFor(
let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
try {
const json = (await getJson(progUrl)) as {
playerstats?: { achievements?: typeof progress };
playerstats?: { achievements?: typeof progress; success?: boolean; error?: string };
};
// success=false with a privacy error means the account's "Game details"
// visibility is not Public — distinct from a game that has no achievements.
if (json.playerstats?.success === false && /not public/i.test(json.playerstats.error ?? "")) {
throw new PrivacyError();
}
progress = json.playerstats?.achievements ?? [];
} catch {
} catch (e) {
if (e instanceof PrivacyError) throw e;
return []; // many games expose no achievements; treat as none
}
@@ -97,7 +122,10 @@ export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
return { ...base, error: "Steam API key and SteamID are required." };
}
const recent = await recentGames(cfg.apiKey, cfg.steamId);
let recent = await recentGames(cfg.apiKey, cfg.steamId);
if (recent.length === 0) {
recent = await ownedGames(cfg.apiKey, cfg.steamId);
}
const games: Game[] = recent.map((g) => ({
platform: "steam",
name: g.name,
@@ -106,15 +134,24 @@ export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
playtimeMinutes: g.playtime_forever,
}));
// Probe the most-recent games in order until one yields unlocked
// achievements — many titles (e.g. older or MP games) expose none. Bounded so
// the request count stays small.
let achievements: Achievement[] = [];
if (recent[0]) {
achievements = await achievementsFor(
cfg.apiKey,
cfg.steamId,
recent[0].appid,
recent[0].name
);
let notice: string | undefined;
for (const g of recent.slice(0, 5)) {
try {
achievements = await achievementsFor(cfg.apiKey, cfg.steamId, g.appid, g.name);
} catch (e) {
if (e instanceof PrivacyError) {
notice =
'Games loaded, but achievements are hidden by Steam privacy. Set Steam → Profile → Edit Profile → Privacy → "Game details" to Public.';
break;
}
throw e;
}
if (achievements.length > 0) break;
}
return { ...base, games, achievements };
return { ...base, games, achievements, notice };
}
+20 -1
View File
@@ -12,7 +12,7 @@ export type SocialLink = {
};
/** The platforms we can auto-fetch gameplay data from. */
export type PlatformId = "steam" | "psn" | "xbox";
export type PlatformId = "steam" | "psn" | "xbox" | "retro";
/** A game surfaced from a platform's "recently played" / activity feed. */
export type Game = {
@@ -44,13 +44,21 @@ export type Achievement = {
/** A console the author owns / has owned (curated by hand). */
export type ConsoleItem = {
/** Registry id when picked from the known-consoles list; absent if free-form. */
id?: string;
name: string;
/** Emoji glyph or image URL, denormalized from the registry for rendering. */
icon?: string;
note?: string;
};
/** An all-time favorite game (curated by hand). */
export type FavoriteGame = {
name: string;
/** Cover / capsule art URL, when added from the game search. */
image?: string;
/** Store / info page link. */
url?: string;
note?: string;
};
@@ -76,6 +84,14 @@ export type XboxConfig = {
xuid: string;
};
export type RetroConfig = {
enabled: boolean;
/** RetroAchievements username (also used as the API caller). */
username: string;
/** Web API key from retroachievements.org/settings. */
apiKey: string;
};
/** Everything the admin curates + the platform credentials. Holds secrets. */
export type IntegrationConfig = {
displayName: string;
@@ -88,6 +104,7 @@ export type IntegrationConfig = {
steam: SteamConfig;
psn: PsnConfig;
xbox: XboxConfig;
retro: RetroConfig;
/** Cache freshness window for platform fetches, in minutes. */
cacheTtlMinutes: number;
};
@@ -99,6 +116,8 @@ export type PlatformData = {
achievements: Achievement[];
/** Human-readable error if the last fetch failed (data may be stale/empty). */
error?: string;
/** Non-fatal hint (e.g. games loaded but achievements are privacy-blocked). */
notice?: string;
/** ISO timestamp of when this payload was fetched. */
fetchedAt: string;
};