454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { getIntegrationConfig, getProfile } from "@/lib/integrations";
|
|
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
|
|
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 fmtAge(iso: string): string {
|
|
const t = new Date(iso).getTime();
|
|
if (Number.isNaN(t)) return "—";
|
|
const mins = Math.round((Date.now() - t) / 60000);
|
|
if (mins < 1) return "just now";
|
|
if (mins < 60) return `${mins} min ago`;
|
|
const hrs = Math.round(mins / 60);
|
|
return hrs < 24 ? `${hrs}h ago` : `${Math.round(hrs / 24)}d ago`;
|
|
}
|
|
|
|
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<Banners>;
|
|
}) {
|
|
const banners = await searchParams;
|
|
const cfg = getIntegrationConfig();
|
|
// Loads (cached) platform data so we can show freshness + any fetch errors.
|
|
const profile = await getProfile();
|
|
const networkIds = SOCIAL_NETWORKS.map((n) => n.id).join(", ");
|
|
|
|
return (
|
|
<div className="rb-admin-page">
|
|
<h1 className="rb-admin-h1">Integrations</h1>
|
|
<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
|
|
the public <a href="/bio" target="_blank">bio page</a>.
|
|
</p>
|
|
|
|
{/* ---- platform status ---- */}
|
|
{profile.platforms.length > 0 && (
|
|
<div className="rb-admin-card">
|
|
<h2 className="rb-admin-h2">Platform status</h2>
|
|
<table className="rb-admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Platform</th>
|
|
<th>Games</th>
|
|
<th>Achievements</th>
|
|
<th>Fetched</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{profile.platforms.map((p) => (
|
|
<tr key={p.platform}>
|
|
<td>{p.platform.toUpperCase()}</td>
|
|
<td>{p.games.length}</td>
|
|
<td>{p.achievements.length}</td>
|
|
<td>{fmtAge(p.fetchedAt)}</td>
|
|
<td>
|
|
{p.error ? (
|
|
<span className="rb-admin-error" style={{ margin: 0 }}>
|
|
{p.error}
|
|
</span>
|
|
) : (
|
|
"OK"
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<form action={refreshIntegrationsAction} className="rb-form-actions">
|
|
<button className="rb-btn" type="submit">
|
|
Refresh now
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<form className="rb-admin-card" action={saveIntegrationsAction}>
|
|
{/* ---- profile ---- */}
|
|
<h2 className="rb-admin-h2">Profile</h2>
|
|
<div className="rb-field-row">
|
|
<label className="rb-field">
|
|
<span>Display name</span>
|
|
<input name="displayName" defaultValue={cfg.displayName} />
|
|
</label>
|
|
<label className="rb-field">
|
|
<span>Avatar image URL</span>
|
|
<input name="avatarUrl" defaultValue={cfg.avatarUrl} placeholder="https://…" />
|
|
</label>
|
|
</div>
|
|
<label className="rb-field">
|
|
<span>
|
|
Bio <em className="rb-admin-muted">(Markdown)</em>
|
|
</span>
|
|
<textarea name="bio" rows={4} className="rb-mono" defaultValue={cfg.bio} />
|
|
</label>
|
|
|
|
{/* ---- social links ---- */}
|
|
<h2 className="rb-admin-h2">Social links</h2>
|
|
<label className="rb-field">
|
|
<span>
|
|
One per line: <code>network|url|label</code>
|
|
<em className="rb-admin-muted"> — networks: {networkIds}</em>
|
|
</span>
|
|
<textarea
|
|
name="social"
|
|
rows={4}
|
|
className="rb-mono"
|
|
placeholder={"github|https://github.com/me\nmastodon|https://mastodon.social/@me"}
|
|
defaultValue={socialToText(cfg.social)}
|
|
/>
|
|
</label>
|
|
|
|
{/* ---- consoles + favorites ---- */}
|
|
<h2 className="rb-admin-h2">Collection</h2>
|
|
<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 ---- */}
|
|
<h2 className="rb-admin-h2">Steam</h2>
|
|
<label className="rb-check">
|
|
<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" : "required for game data"})
|
|
</em>
|
|
</span>
|
|
<input
|
|
name="steamApiKey"
|
|
type="password"
|
|
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">
|
|
<input type="checkbox" name="psnEnabled" defaultChecked={cfg.psn.enabled} />
|
|
<span>Enable PSN integration</span>
|
|
</label>
|
|
<label className="rb-field">
|
|
<span>
|
|
NPSSO token{" "}
|
|
<em className="rb-admin-muted">
|
|
({cfg.psn.npsso ? "stored — blank keeps it" : "required"})
|
|
</em>
|
|
</span>
|
|
<input
|
|
name="psnNpsso"
|
|
type="password"
|
|
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">
|
|
<input type="checkbox" name="xboxEnabled" defaultChecked={cfg.xbox.enabled} />
|
|
<span>Enable Xbox integration</span>
|
|
</label>
|
|
<div className="rb-field-row">
|
|
<label className="rb-field">
|
|
<span>
|
|
OpenXBL API key{" "}
|
|
<em className="rb-admin-muted">
|
|
({cfg.xbox.apiKey ? "stored — blank keeps it" : "required"})
|
|
</em>
|
|
</span>
|
|
<input
|
|
name="xboxApiKey"
|
|
type="password"
|
|
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>
|
|
<label className="rb-field">
|
|
<span>
|
|
Refresh interval <em className="rb-admin-muted">(minutes)</em>
|
|
</span>
|
|
<input
|
|
name="cacheTtlMinutes"
|
|
type="number"
|
|
min={1}
|
|
defaultValue={cfg.cacheTtlMinutes}
|
|
/>
|
|
</label>
|
|
|
|
<div className="rb-form-actions">
|
|
<button className="rb-btn rb-btn-primary" type="submit">
|
|
Save integrations
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|