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
+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>