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;