feat: add platform integrations + gamer bio system

Blog can now pull live gaming data and surface a curated gamer profile.

- DB: integrations config table (single JSON row, holds secrets, excluded
  from export), integration_cache table (lazy TTL cache), posts.links column
- Platform fetchers: Steam (official Web API), PSN (NPSSO->token->trophies),
  Xbox (OpenXBL). All degrade gracefully; cache keeps last-good payload on
  fetch failure so pages never blank
- Admin /integrations: profile/bio, social links, consoles, favorites,
  per-platform creds (blank keeps stored secret), cache TTL, live status table
  with refresh
- Public /bio page: avatar, bio, recent games, achievements, favorites,
  consoles. Gated behind hasIntegrations() so a vanilla blog stays clean
- About page bio teaser, Shell "now playing" tray widget + Bio nav link
- Per-post "shared on" social links (editable in PostForm, shown on post page)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 04:26:51 +02:00
parent 2f373e683b
commit 1f195a16de
21 changed files with 1822 additions and 3 deletions
+263
View File
@@ -0,0 +1,263 @@
import { getIntegrationConfig, getProfile } from "@/lib/integrations";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import type {
ConsoleItem,
FavoriteGame,
SocialLink,
} from "@/lib/integrations/types";
import {
saveIntegrationsAction,
refreshIntegrationsAction,
} from "../../actions";
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();
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`;
}
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>;
return null;
}
export default async function IntegrationsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; refreshed?: string }>;
}) {
const { saved, refreshed } = 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 saved={saved} refreshed={refreshed} />
<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-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>
{/* ---- 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>
<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"})
</em>
</span>
<input
name="steamApiKey"
type="password"
autoComplete="off"
placeholder={cfg.steam.apiKey ? "•••••••• stored" : ""}
/>
</label>
<label className="rb-field">
<span>SteamID (64-bit)</span>
<input name="steamId" defaultValue={cfg.steam.steamId} placeholder="7656119…" />
</label>
</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" : "from ca.account.sony.com/api/v1/ssocookie"})
</em>
</span>
<input
name="psnNpsso"
type="password"
autoComplete="off"
placeholder={cfg.psn.npsso ? "•••••••• stored" : "64-char token"}
/>
</label>
<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" : "from xbl.io"})
</em>
</span>
<input
name="xboxApiKey"
type="password"
autoComplete="off"
placeholder={cfg.xbox.apiKey ? "•••••••• stored" : ""}
/>
</label>
<label className="rb-field">
<span>
XUID <em className="rb-admin-muted">(optional)</em>
</span>
<input name="xboxXuid" defaultValue={cfg.xbox.xuid} />
</label>
</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>
);
}