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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user