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
+18
View File
@@ -1,9 +1,14 @@
import Link from "next/link";
import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import { THEMES } from "@/themes/registry";
import { getIntegrationConfig, hasIntegrations } from "@/lib/integrations";
export default async function AboutPage() {
const theme = await getTheme();
const showBio = hasIntegrations();
const cfg = showBio ? getIntegrationConfig() : null;
return (
<Shell theme={theme} title="About">
<article className="rb-article">
@@ -30,6 +35,19 @@ export default async function AboutPage() {
file and registering it no markup changes required.
</p>
</div>
{showBio && cfg && (
<div className="rb-about-bio rb-prose">
<h2>About the webmaster</h2>
<p>
{cfg.displayName ? <strong>{cfg.displayName}</strong> : "The webmaster"}{" "}
keeps a living gamer profile here recently played titles,
achievements, the console shelf, and all-time favorites.{" "}
<Link href="/bio">See the full bio </Link>
</p>
<SocialLinks links={cfg.social} />
</div>
)}
</article>
</Shell>
);
+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>
);
}
+1
View File
@@ -21,6 +21,7 @@ export default async function PanelLayout({
<nav className="rb-admin-nav">
<Link href="/admin">Dashboard</Link>
<Link href="/admin/posts">Posts</Link>
<Link href="/admin/integrations">Integrations</Link>
<Link href="/admin/settings">Settings</Link>
<Link href="/" target="_blank">
View site
+19
View File
@@ -1,5 +1,7 @@
import Link from "next/link";
import type { Post } from "@/lib/posts";
import { linksToText } from "@/lib/posts";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import { savePostAction } from "../../actions";
// Server-rendered create/edit form. Both modes post to the same action; the
@@ -80,6 +82,23 @@ export default function PostForm({
/>
</label>
<label className="rb-field">
<span>
Shared on{" "}
<em className="rb-admin-muted">
(one per line: network|url|label networks:{" "}
{SOCIAL_NETWORKS.map((n) => n.id).join(", ")})
</em>
</span>
<textarea
name="links"
rows={3}
className="rb-mono"
placeholder={"mastodon|https://mastodon.social/@me/123|Discuss\nx|https://x.com/me/status/123"}
defaultValue={post ? linksToText(post.links) : ""}
/>
</label>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
{editing ? "Save changes" : "Create post"}
+80
View File
@@ -21,6 +21,18 @@ import {
normalizeSettings,
type Settings,
} from "@/lib/settings";
import {
getIntegrationConfig,
saveIntegrationConfig,
refreshPlatforms,
} from "@/lib/integrations";
import { isSocialNetworkId, type SocialNetworkId } from "@/lib/integrations/social";
import type {
ConsoleItem,
FavoriteGame,
IntegrationConfig,
SocialLink,
} from "@/lib/integrations/types";
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
function s(formData: FormData, key: string): string {
@@ -70,6 +82,7 @@ function postInputFromForm(formData: FormData): PostInput {
body: s(formData, "body"),
author: s(formData, "author"),
tags: s(formData, "tags"),
links: s(formData, "links"),
createdAt: s(formData, "createdAt"),
};
}
@@ -183,3 +196,70 @@ export async function importAction(formData: FormData) {
revalidateSite();
redirect("/admin/settings?import=ok");
}
/* ---------------------------- integrations --------------------------- */
// Each line: `network|url|label?`. Unknown networks / blank urls are dropped.
function parseSocialLines(raw: string): SocialLink[] {
const out: SocialLink[] = [];
for (const line of raw.split("\n")) {
const [network, url, ...rest] = line.split("|").map((p) => p.trim());
if (!isSocialNetworkId(network) || !url) continue;
const label = rest.join("|").trim();
out.push({ network: network as SocialNetworkId, url, label: label || undefined });
}
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 });
}
return out;
}
export async function saveIntegrationsAction(formData: FormData) {
const current = getIntegrationConfig();
const next: IntegrationConfig = {
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[],
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(),
},
psn: {
enabled: formData.get("psnEnabled") === "on",
npsso: s(formData, "psnNpsso").trim() || current.psn.npsso,
},
xbox: {
enabled: formData.get("xboxEnabled") === "on",
apiKey: s(formData, "xboxApiKey").trim() || current.xbox.apiKey,
xuid: s(formData, "xboxXuid").trim(),
},
cacheTtlMinutes: Number(s(formData, "cacheTtlMinutes")) || current.cacheTtlMinutes,
};
saveIntegrationConfig(next);
// Config changed (creds/toggles) — drop cached payloads so they refetch.
refreshPlatforms();
revalidateSite();
redirect("/admin/integrations?saved=1");
}
export async function refreshIntegrationsAction() {
refreshPlatforms();
revalidateSite();
redirect("/admin/integrations?refreshed=1");
}
+20
View File
@@ -390,3 +390,23 @@
.rb-login-card .rb-admin-h1 {
margin-bottom: 6px;
}
/* integrations: platform status table */
.rb-admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
margin: 8px 0;
}
.rb-admin-table th,
.rb-admin-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid #e2e2e2;
vertical-align: top;
}
.rb-admin-table th {
font-weight: 600;
color: #555;
border-bottom: 2px solid #d0d0d0;
}
+162
View File
@@ -0,0 +1,162 @@
import { notFound } from "next/navigation";
import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import { getProfile, hasIntegrations } from "@/lib/integrations";
import type { Achievement, Game } from "@/lib/integrations/types";
function platformLabel(p: string): string {
return p === "psn" ? "PlayStation" : p === "xbox" ? "Xbox" : "Steam";
}
function fmtPlaytime(mins?: number): string | null {
if (!mins || mins < 1) return null;
const h = Math.round(mins / 60);
return h >= 1 ? `${h}h played` : `${mins}m played`;
}
function GameCard({ game }: { game: Game }) {
const body = (
<>
{game.image && (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-game-art" src={game.image} alt="" loading="lazy" />
)}
<span className="rb-game-info">
<span className="rb-game-name">{game.name}</span>
<span className="rb-game-sub">
<span className="rb-badge">{platformLabel(game.platform)}</span>
{fmtPlaytime(game.playtimeMinutes) && (
<span className="rb-game-time">{fmtPlaytime(game.playtimeMinutes)}</span>
)}
</span>
</span>
</>
);
return game.url ? (
<a className="rb-game-card" href={game.url} target="_blank" rel="noreferrer">
{body}
</a>
) : (
<div className="rb-game-card">{body}</div>
);
}
function AchievementRow({ a }: { a: Achievement }) {
return (
<li className="rb-ach">
{a.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-ach-icon" src={a.icon} alt="" loading="lazy" />
) : (
<span className="rb-ach-icon rb-ach-icon-empty" aria-hidden>
🏆
</span>
)}
<span className="rb-ach-body">
<span className="rb-ach-name">{a.name}</span>
<span className="rb-ach-meta">
{a.game} · {platformLabel(a.platform)}
{a.rarity ? ` · ${a.rarity}` : ""}
</span>
{a.description && <span className="rb-ach-desc">{a.description}</span>}
</span>
</li>
);
}
export default async function BioPage() {
if (!hasIntegrations()) notFound();
const theme = await getTheme();
const profile = await getProfile();
const name = profile.displayName || "The Webmaster";
return (
<Shell theme={theme} title="Bio">
<div className="rb-bio">
{/* ---- header ---- */}
<header className="rb-bio-header">
{profile.avatarUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-bio-avatar" src={profile.avatarUrl} alt={name} />
)}
<div className="rb-bio-head-text">
<h1 className="rb-article-title">{name}</h1>
{profile.bioHtml && (
<div
className="rb-prose"
dangerouslySetInnerHTML={{ __html: profile.bioHtml }}
/>
)}
<SocialLinks links={profile.social} className="rb-bio-social" />
</div>
</header>
{/* ---- recent games ---- */}
{profile.games.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Recently played</h2>
<div className="rb-game-grid">
{profile.games.slice(0, 12).map((g, i) => (
<GameCard key={`${g.platform}-${g.name}-${i}`} game={g} />
))}
</div>
</section>
)}
{/* ---- achievements ---- */}
{profile.achievements.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Recent achievements</h2>
<ul className="rb-ach-list">
{profile.achievements.slice(0, 12).map((a, i) => (
<AchievementRow key={`${a.platform}-${a.name}-${i}`} a={a} />
))}
</ul>
</section>
)}
<div className="rb-bio-cols">
{/* ---- favorites ---- */}
{profile.favorites.length > 0 && (
<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>
))}
</ul>
</section>
)}
{/* ---- consoles ---- */}
{profile.consoles.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Consoles</h2>
<ul className="rb-console-list">
{profile.consoles.map((c, i) => (
<li key={i} className="rb-console">
<span className="rb-console-name">{c.name}</span>
{c.note && <span className="rb-console-note">{c.note}</span>}
</li>
))}
</ul>
</section>
)}
</div>
{/* ---- platform fetch errors (subtle) ---- */}
{profile.platforms.some((p) => p.error) && (
<p className="rb-bio-note">
Some platforms couldn&apos;t be reached right now; showing the last
known data.
</p>
)}
</div>
</Shell>
);
}
+258
View File
@@ -193,3 +193,261 @@ img {
:root {
color-scheme: light;
}
/* ---------------------------------------------------------------------------
Integrations: bio page, social links, game/achievement cards, tray widget.
Theme-agnostic base styling — themes may override these rb- classes, but
these defaults keep everything legible under any skin.
--------------------------------------------------------------------------- */
/* social links row */
.rb-social {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rb-social-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid currentColor;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em;
line-height: 1.4;
}
.rb-social-link:hover {
filter: brightness(0.95);
}
.rb-social-icon {
font-size: 1.1em;
}
/* per-post share footer */
.rb-article-share {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
.rb-article-share-label {
font-weight: bold;
opacity: 0.8;
}
.rb-article-share .rb-social {
margin: 0;
}
/* bio page layout */
.rb-bio {
display: flex;
flex-direction: column;
gap: 26px;
}
.rb-bio-header {
display: flex;
gap: 18px;
align-items: flex-start;
flex-wrap: wrap;
}
.rb-bio-avatar {
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.2);
flex: 0 0 auto;
}
.rb-bio-head-text {
flex: 1 1 260px;
min-width: 0;
}
.rb-bio-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.rb-bio-h2 {
margin: 0;
font-size: 1.15em;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
padding-bottom: 4px;
}
.rb-bio-cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 26px;
}
.rb-bio-note {
font-size: 0.85em;
opacity: 0.7;
font-style: italic;
}
/* game cards */
.rb-game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.rb-game-card {
display: flex;
flex-direction: column;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
overflow: hidden;
text-decoration: none;
background: rgba(255, 255, 255, 0.35);
}
.rb-game-card:hover {
filter: brightness(1.03);
}
.rb-game-art {
width: 100%;
aspect-ratio: 460 / 215;
object-fit: cover;
display: block;
}
.rb-game-info {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
}
.rb-game-name {
font-weight: bold;
line-height: 1.2;
}
.rb-game-sub {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8em;
opacity: 0.85;
}
.rb-badge {
display: inline-block;
padding: 1px 6px;
border: 1px solid currentColor;
border-radius: 3px;
font-size: 0.9em;
}
/* achievements */
.rb-ach-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rb-ach {
display: flex;
gap: 10px;
align-items: flex-start;
}
.rb-ach-icon {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex: 0 0 auto;
}
.rb-ach-icon-empty {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.rb-ach-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rb-ach-name {
font-weight: bold;
}
.rb-ach-meta {
font-size: 0.8em;
opacity: 0.75;
}
.rb-ach-desc {
font-size: 0.85em;
opacity: 0.9;
}
/* favorites + consoles lists */
.rb-fav-list,
.rb-console-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.rb-fav,
.rb-console {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 10px;
border-left: 3px solid currentColor;
background: rgba(0, 0, 0, 0.04);
}
.rb-fav-name,
.rb-console-name {
font-weight: bold;
}
.rb-fav-note,
.rb-console-note {
font-size: 0.85em;
opacity: 0.8;
}
.rb-about-bio {
margin-top: 22px;
padding-top: 14px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
/* now-playing tray widget */
.rb-nowplaying {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
font-size: 0.8em;
padding: 2px 8px;
max-width: 220px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.rb-nowplaying-label {
overflow: hidden;
text-overflow: ellipsis;
}
.rb-nowplaying-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #36c436;
box-shadow: 0 0 4px #36c436;
flex: 0 0 auto;
animation: rb-pulse 2s ease-in-out infinite;
}
@keyframes rb-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
+7
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import {
getAllPosts,
@@ -47,6 +48,12 @@ export default async function PostPage({
className="rb-prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
{post.links.length > 0 && (
<footer className="rb-article-share">
<span className="rb-article-share-label">Shared on:</span>
<SocialLinks links={post.links} />
</footer>
)}
</article>
</Shell>
);
+19
View File
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode } from "react";
import { THEMES, type ThemeId } from "@/themes/registry";
import { getSettings } from "@/lib/settings";
import { isAdmin } from "@/lib/auth";
import { getNowPlaying, hasIntegrations } from "@/lib/integrations";
import ThemeSwitcher from "./ThemeSwitcher";
import Clock from "./Clock";
@@ -20,6 +21,11 @@ export default async function Shell({
const settings = getSettings();
const admin = await isAdmin();
// Gamer bio surfaces: nav link + a "now playing" tray widget. Both gate on the
// integrations being configured so a vanilla blog stays clean.
const bioEnabled = hasIntegrations();
const nowPlaying = bioEnabled ? await getNowPlaying() : null;
// Admins always get the switcher with every skin; the public only sees it when
// enabled, and only the allowed skins.
const showSwitcher = admin || settings.publicThemeToggle;
@@ -79,6 +85,11 @@ export default async function Shell({
<Link className="rb-menu-link" href="/about">
About
</Link>
{bioEnabled && (
<Link className="rb-menu-link" href="/bio">
Bio
</Link>
)}
{admin && (
<Link className="rb-menu-link" href="/admin">
Admin
@@ -112,6 +123,14 @@ export default async function Shell({
</button>
</div>
<div className="rb-tray">
{nowPlaying && (
<Link className="rb-nowplaying" href="/bio" title="Recently played">
<span className="rb-nowplaying-dot" aria-hidden />
<span className="rb-nowplaying-label">
Playing: <strong>{nowPlaying.name}</strong>
</span>
</Link>
)}
<Clock />
</div>
</div>
+36
View File
@@ -0,0 +1,36 @@
import { socialNetwork } from "@/lib/integrations/social";
import type { SocialLink } from "@/lib/integrations/types";
// Theme-agnostic row of social links. Reused for the curated profile links and
// for per-post "shared on" links. Styling lives in the `rb-social*` classes.
export default function SocialLinks({
links,
className = "",
}: {
links: SocialLink[];
className?: string;
}) {
if (links.length === 0) return null;
return (
<ul className={`rb-social ${className}`.trim()}>
{links.map((l, i) => {
const net = socialNetwork(l.network);
return (
<li key={`${l.network}-${i}`}>
<a
className="rb-social-link"
href={l.url}
target="_blank"
rel="me noreferrer"
>
<span className="rb-social-icon" aria-hidden>
{net.icon}
</span>
<span className="rb-social-name">{l.label ?? net.name}</span>
</a>
</li>
);
})}
</ul>
);
}
+25
View File
@@ -41,7 +41,32 @@ function migrate(db: Database.Database) {
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
);
-- Single-row blob holding the integrations config JSON (platform creds,
-- curated bio/social/consoles/favorites). Holds secrets — never exported.
CREATE TABLE IF NOT EXISTS integrations (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
);
-- Lazy TTL cache of data fetched from external platform APIs. One row per
-- (platform) payload; refreshed on demand when stale.
CREATE TABLE IF NOT EXISTS integration_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
error TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Per-post social-share links (JSON array). Added via ALTER so existing
// databases pick it up; guarded because SQLite has no ADD COLUMN IF NOT EXISTS.
const hasLinks = (
db.prepare("PRAGMA table_info(posts)").all() as { name: string }[]
).some((c) => c.name === "links");
if (!hasLinks) {
db.exec("ALTER TABLE posts ADD COLUMN links TEXT NOT NULL DEFAULT '[]'");
}
}
function seed(db: Database.Database) {
+89
View File
@@ -0,0 +1,89 @@
import "server-only";
import { getDb } from "../db";
// Lazy TTL cache for external platform fetches. Each platform stores one row.
// On a fresh hit we return the cached payload; when stale we run the fetcher,
// persist the result, and — crucially — fall back to the last good payload if
// the fetcher fails, so a transient API outage never blanks the bio page.
type CacheRow = {
data: string;
error: string | null;
fetched_at: string;
};
function read(key: string): CacheRow | undefined {
return getDb()
.prepare("SELECT data, error, fetched_at FROM integration_cache WHERE key = ?")
.get(key) as CacheRow | undefined;
}
function write(key: string, data: string, error: string | null): void {
getDb()
.prepare(
`INSERT INTO integration_cache (key, data, error, fetched_at)
VALUES (@key, @data, @error, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = @data, error = @error,
fetched_at = datetime('now')`
)
.run({ key, data, error });
}
function ageMinutes(iso: string): number {
const t = new Date(iso.replace(" ", "T") + "Z").getTime();
if (Number.isNaN(t)) return Infinity;
return (Date.now() - t) / 60000;
}
export type Cached<T> = { data: T; fetchedAt: string; error?: string };
/**
* Return cached `key` data if younger than `ttlMinutes`, otherwise refetch.
* The fetcher itself should never throw (catch internally) but we guard anyway.
*/
export async function cached<T>(
key: string,
ttlMinutes: number,
fallback: T,
fetcher: () => Promise<T>
): Promise<Cached<T>> {
const row = read(key);
if (row && ageMinutes(row.fetched_at) < ttlMinutes) {
try {
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: row.error ?? undefined };
} catch {
/* fall through to refetch on corrupt cache */
}
}
try {
const fresh = await fetcher();
write(key, JSON.stringify(fresh), null);
return { data: fresh, fetchedAt: new Date().toISOString() };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Keep the previous good payload visible; just surface the error.
if (row) {
try {
write(key, row.data, msg);
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: msg };
} catch {
/* corrupt previous payload — fall through */
}
}
write(key, JSON.stringify(fallback), msg);
return { data: fallback, fetchedAt: new Date().toISOString(), error: msg };
}
}
/** Force the next read of these keys to refetch by deleting their rows. */
export function clearCache(keys?: string[]): void {
const db = getDb();
if (!keys || keys.length === 0) {
db.prepare("DELETE FROM integration_cache").run();
return;
}
const stmt = db.prepare("DELETE FROM integration_cache WHERE key = ?");
const tx = db.transaction((ks: string[]) => ks.forEach((k) => stmt.run(k)));
tx(keys);
}
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import { getDb } from "../db";
import { isSocialNetworkId, type SocialNetworkId } from "./social";
import type {
ConsoleItem,
FavoriteGame,
IntegrationConfig,
SocialLink,
} from "./types";
// Integrations config persisted as a single JSON row, mirroring the settings
// module. Holds platform credentials (secrets) alongside curated profile
// content, so it is deliberately NOT part of the export/import surface.
export const DEFAULT_CONFIG: IntegrationConfig = {
displayName: "",
bio: "",
avatarUrl: "",
social: [],
consoles: [],
favorites: [],
steam: { enabled: false, apiKey: "", steamId: "" },
psn: { enabled: false, npsso: "" },
xbox: { enabled: false, apiKey: "", xuid: "" },
cacheTtlMinutes: 360,
};
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function bool(v: unknown, fallback = false): boolean {
return typeof v === "boolean" ? v : fallback;
}
function normSocial(input: unknown): SocialLink[] {
if (!Array.isArray(input)) return [];
const out: SocialLink[] = [];
for (const raw of input) {
const o = (raw ?? {}) as Record<string, unknown>;
const network = o.network;
const url = str(o.url).trim();
if (!isSocialNetworkId(network) || !url) continue;
out.push({
network: network as SocialNetworkId,
url,
label: str(o.label).trim() || undefined,
});
}
return out;
}
function normConsoles(input: unknown): ConsoleItem[] {
if (!Array.isArray(input)) return [];
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
})
.filter((c) => c.name);
}
function normFavorites(input: unknown): FavoriteGame[] {
if (!Array.isArray(input)) return [];
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
})
.filter((f) => f.name);
}
export function normalizeConfig(input: unknown): IntegrationConfig {
const o = (input ?? {}) as Record<string, unknown>;
const steam = (o.steam ?? {}) as Record<string, unknown>;
const psn = (o.psn ?? {}) as Record<string, unknown>;
const xbox = (o.xbox ?? {}) as Record<string, unknown>;
const ttl = Number(o.cacheTtlMinutes);
return {
displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
bio: str(o.bio, DEFAULT_CONFIG.bio),
avatarUrl: str(o.avatarUrl, DEFAULT_CONFIG.avatarUrl).trim(),
social: normSocial(o.social),
consoles: normConsoles(o.consoles),
favorites: normFavorites(o.favorites),
steam: {
enabled: bool(steam.enabled),
apiKey: str(steam.apiKey).trim(),
steamId: str(steam.steamId).trim(),
},
psn: {
enabled: bool(psn.enabled),
npsso: str(psn.npsso).trim(),
},
xbox: {
enabled: bool(xbox.enabled),
apiKey: str(xbox.apiKey).trim(),
xuid: str(xbox.xuid).trim(),
},
cacheTtlMinutes:
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
};
}
export function getIntegrationConfig(): IntegrationConfig {
const row = getDb()
.prepare("SELECT data FROM integrations WHERE id = 1")
.get() as { data: string } | undefined;
if (!row) return DEFAULT_CONFIG;
try {
return normalizeConfig(JSON.parse(row.data));
} catch {
return DEFAULT_CONFIG;
}
}
export function saveIntegrationConfig(next: IntegrationConfig): IntegrationConfig {
const clean = normalizeConfig(next);
getDb()
.prepare(
`INSERT INTO integrations (id, data) VALUES (1, @data)
ON CONFLICT(id) DO UPDATE SET data = @data`
)
.run({ data: JSON.stringify(clean) });
return clean;
}
+127
View File
@@ -0,0 +1,127 @@
import "server-only";
import { marked } from "marked";
import { cached, clearCache, type Cached } from "./cache";
import { getIntegrationConfig } from "./config";
import { fetchSteam } from "./steam";
import { fetchPsn } from "./psn";
import { fetchXbox } from "./xbox";
import type {
Game,
IntegrationConfig,
PlatformData,
PlatformId,
Profile,
} from "./types";
export { getIntegrationConfig, saveIntegrationConfig, DEFAULT_CONFIG } from "./config";
export type { IntegrationConfig } from "./types";
const CACHE_KEYS: Record<PlatformId, string> = {
steam: "platform:steam",
psn: "platform:psn",
xbox: "platform:xbox",
};
function emptyPlatform(platform: PlatformId): PlatformData {
return { platform, games: [], achievements: [], fetchedAt: new Date().toISOString() };
}
/** Fetch one platform through the TTL cache. */
async function loadPlatform(
platform: PlatformId,
cfg: IntegrationConfig
): Promise<PlatformData> {
const ttl = cfg.cacheTtlMinutes;
const key = CACHE_KEYS[platform];
const fallback = emptyPlatform(platform);
let result: Cached<PlatformData>;
switch (platform) {
case "steam":
result = await cached(key, ttl, fallback, () => fetchSteam(cfg.steam));
break;
case "psn":
result = await cached(key, ttl, fallback, () => fetchPsn(cfg.psn));
break;
case "xbox":
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
break;
}
// Surface a cache-layer error onto the payload if the fetch's own error is unset.
return result.error && !result.data.error
? { ...result.data, error: result.error, fetchedAt: result.fetchedAt }
: { ...result.data, fetchedAt: result.fetchedAt };
}
function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
const out: PlatformId[] = [];
if (cfg.steam.enabled) out.push("steam");
if (cfg.psn.enabled) out.push("psn");
if (cfg.xbox.enabled) out.push("xbox");
return out;
}
function gameTime(g: Game): number {
return g.lastPlayed ? new Date(g.lastPlayed).getTime() : 0;
}
/** Assemble the full public profile: curated content + cached platform data. */
export async function getProfile(): Promise<Profile> {
const cfg = getIntegrationConfig();
const platforms = await Promise.all(
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
);
const games = platforms
.flatMap((p) => p.games)
.sort((a, b) => gameTime(b) - gameTime(a));
const achievements = platforms
.flatMap((p) => p.achievements)
.sort(
(a, b) =>
new Date(b.unlockedAt ?? 0).getTime() - new Date(a.unlockedAt ?? 0).getTime()
);
return {
displayName: cfg.displayName,
bioHtml: cfg.bio ? (marked.parse(cfg.bio, { async: false }) as string) : "",
avatarUrl: cfg.avatarUrl,
social: cfg.social,
consoles: cfg.consoles,
favorites: cfg.favorites,
games,
achievements,
platforms,
};
}
/** Latest game across all enabled platforms — for the persistent shell widget. */
export async function getNowPlaying(): Promise<Game | null> {
const cfg = getIntegrationConfig();
if (enabledPlatforms(cfg).length === 0) return null;
const platforms = await Promise.all(
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
);
const games = platforms.flatMap((p) => p.games);
if (games.length === 0) return null;
// Prefer most-recently-played; platforms without timestamps fall to list order.
const timed = games.filter((g) => g.lastPlayed);
if (timed.length) return timed.sort((a, b) => gameTime(b) - gameTime(a))[0];
return games[0];
}
/** True if any platform integration is switched on (gates the public bio link). */
export function hasIntegrations(): boolean {
const cfg = getIntegrationConfig();
return (
enabledPlatforms(cfg).length > 0 ||
cfg.social.length > 0 ||
cfg.consoles.length > 0 ||
cfg.favorites.length > 0 ||
cfg.bio.trim().length > 0
);
}
/** Drop cached platform payloads so the next read refetches. */
export function refreshPlatforms(): void {
clearCache(Object.values(CACHE_KEYS));
}
+124
View File
@@ -0,0 +1,124 @@
import "server-only";
import type { Achievement, Game, PlatformData, PsnConfig } from "./types";
// PlayStation Network has no official public API. We replicate the well-known
// NPSSO -> authorization-code -> access-token exchange (the same flow the
// `psn-api` library performs) using raw fetch, then read the user's trophy
// titles. This is ToS-gray and brittle by nature; every step degrades to a
// thrown Error which the cache layer turns into a surfaced status, never a crash.
const AUTH_BASE = "https://ca.account.sony.com/api/authz/v3/oauth";
const API_BASE = "https://m.np.playstation.com/api";
const CLIENT_ID = "09515159-7237-4370-9b40-3806e67c0891";
const REDIRECT = "com.scee.psxandroid.scecompcall://redirect";
// Public basic-auth pair used by the PSN mobile client (client_id:client_secret).
const BASIC = "MDk1MTUxNTktNzIzNy00MzcwLTliNDAtMzgwNmU2N2MwODkxOnVjUGprYTV0bnRCMktxc1A=";
const TIMEOUT = 8000;
/** NPSSO cookie -> short-lived authorization code. */
async function exchangeCode(npsso: string): Promise<string> {
const params = new URLSearchParams({
access_type: "offline",
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
response_type: "code",
scope: "psn:mobile.v2.core psn:clientapp",
});
const res = await fetch(`${AUTH_BASE}/authorize?${params}`, {
method: "GET",
redirect: "manual",
headers: { Cookie: `npsso=${npsso}` },
signal: AbortSignal.timeout(TIMEOUT),
});
const location = res.headers.get("location") ?? "";
const code = new URL(location, "https://ca.account.sony.com").searchParams.get("code");
if (!code) throw new Error("PSN: could not obtain auth code (NPSSO expired or invalid).");
return code;
}
/** Authorization code -> access token. */
async function exchangeToken(code: string): Promise<string> {
const res = await fetch(`${AUTH_BASE}/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BASIC}`,
},
body: new URLSearchParams({
code,
redirect_uri: REDIRECT,
grant_type: "authorization_code",
token_format: "jwt",
}),
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`PSN token exchange failed (${res.status}).`);
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("PSN: no access token returned.");
return json.access_token;
}
type TrophyTitle = {
npCommunicationId: string;
trophyTitleName: string;
trophyTitleIconUrl?: string;
trophyTitlePlatform?: string;
lastUpdatedDateTime?: string;
earnedTrophies?: { bronze: number; silver: number; gold: number; platinum: number };
};
async function trophyTitles(token: string): Promise<TrophyTitle[]> {
const res = await fetch(
`${API_BASE}/trophy/v1/users/me/trophyTitles?limit=16`,
{
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT),
}
);
if (!res.ok) throw new Error(`PSN trophyTitles failed (${res.status}).`);
const json = (await res.json()) as { trophyTitles?: TrophyTitle[] };
return json.trophyTitles ?? [];
}
export async function fetchPsn(cfg: PsnConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "psn",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.npsso) return { ...base, error: "PSN NPSSO token is required." };
const token = await exchangeToken(await exchangeCode(cfg.npsso));
const titles = await trophyTitles(token);
const sorted = [...titles].sort(
(a, b) =>
new Date(b.lastUpdatedDateTime ?? 0).getTime() -
new Date(a.lastUpdatedDateTime ?? 0).getTime()
);
const games: Game[] = sorted.map((t) => ({
platform: "psn",
name: t.trophyTitleName,
image: t.trophyTitleIconUrl,
lastPlayed: t.lastUpdatedDateTime,
}));
// Surface platinum trophies as headline achievements (the API does not return
// individual trophy unlocks without a per-title call; platinums are the prize).
const achievements: Achievement[] = sorted
.filter((t) => (t.earnedTrophies?.platinum ?? 0) > 0)
.slice(0, 8)
.map((t) => ({
platform: "psn" as const,
game: t.trophyTitleName,
name: "Platinum Trophy",
icon: t.trophyTitleIconUrl,
unlockedAt: t.lastUpdatedDateTime,
rarity: "platinum",
}));
return { ...base, games, achievements };
}
+57
View File
@@ -0,0 +1,57 @@
// Registry of known social networks. Used to render icons + labels for curated
// profile links and per-post share links. Icons are emoji to avoid shipping any
// image assets — they reskin fine across every theme.
export type SocialNetworkId =
| "x"
| "mastodon"
| "bluesky"
| "github"
| "youtube"
| "twitch"
| "instagram"
| "tiktok"
| "linkedin"
| "discord"
| "reddit"
| "steam"
| "psn"
| "xbox"
| "rss"
| "website";
export type SocialNetwork = {
id: SocialNetworkId;
name: string;
/** Emoji glyph used as a lightweight, theme-agnostic icon. */
icon: string;
};
export const SOCIAL_NETWORKS: SocialNetwork[] = [
{ id: "x", name: "X / Twitter", icon: "𝕏" },
{ id: "mastodon", name: "Mastodon", icon: "🐘" },
{ id: "bluesky", name: "Bluesky", icon: "🦋" },
{ id: "github", name: "GitHub", icon: "🐙" },
{ id: "youtube", name: "YouTube", icon: "▶️" },
{ id: "twitch", name: "Twitch", icon: "🎮" },
{ id: "instagram", name: "Instagram", icon: "📷" },
{ id: "tiktok", name: "TikTok", icon: "🎵" },
{ id: "linkedin", name: "LinkedIn", icon: "💼" },
{ id: "discord", name: "Discord", icon: "💬" },
{ id: "reddit", name: "Reddit", icon: "👽" },
{ id: "steam", name: "Steam", icon: "🕹️" },
{ id: "psn", name: "PlayStation", icon: "🎯" },
{ id: "xbox", name: "Xbox", icon: "🟢" },
{ id: "rss", name: "RSS", icon: "📡" },
{ id: "website", name: "Website", icon: "🌐" },
];
const BY_ID = new Map(SOCIAL_NETWORKS.map((n) => [n.id, n]));
export function isSocialNetworkId(v: unknown): v is SocialNetworkId {
return typeof v === "string" && BY_ID.has(v as SocialNetworkId);
}
export function socialNetwork(id: SocialNetworkId): SocialNetwork {
return BY_ID.get(id) ?? { id: "website", name: "Website", icon: "🌐" };
}
+120
View File
@@ -0,0 +1,120 @@
import "server-only";
import type { Achievement, Game, PlatformData, SteamConfig } from "./types";
// Steam Web API. The only platform here with a sane, official, key-based API.
// Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API
//
// We pull the recently-played games, then fetch unlocked achievements (with
// human names from the game schema) for the single most-recent title to keep
// the request count bounded.
const API = "https://api.steampowered.com";
const TIMEOUT = 8000;
function header(appid: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
}
async function getJson(url: string): Promise<unknown> {
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) throw new Error(`Steam API ${res.status} ${res.statusText}`);
return res.json();
}
type RecentGame = {
appid: number;
name: string;
playtime_forever?: number;
playtime_2weeks?: number;
};
async function recentGames(key: string, steamId: string): Promise<RecentGame[]> {
const url = `${API}/IPlayerService/GetRecentlyPlayedGames/v1/?key=${key}&steamid=${steamId}&count=12&format=json`;
const json = (await getJson(url)) as { response?: { games?: RecentGame[] } };
return json.response?.games ?? [];
}
type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string };
async function achievementsFor(
key: string,
steamId: string,
appid: number,
gameName: string
): Promise<Achievement[]> {
// Player progress.
const progUrl = `${API}/ISteamUserStats/GetPlayerAchievements/v1/?key=${key}&steamid=${steamId}&appid=${appid}&l=en`;
let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
try {
const json = (await getJson(progUrl)) as {
playerstats?: { achievements?: typeof progress };
};
progress = json.playerstats?.achievements ?? [];
} catch {
return []; // many games expose no achievements; treat as none
}
// Names + icons come from the game schema.
const schemaUrl = `${API}/ISteamUserStats/GetSchemaForGame/v2/?key=${key}&appid=${appid}&l=en`;
const schema = new Map<string, SchemaAch>();
try {
const json = (await getJson(schemaUrl)) as {
game?: { availableGameStats?: { achievements?: SchemaAch[] } };
};
for (const a of json.game?.availableGameStats?.achievements ?? []) {
schema.set(a.name, a);
}
} catch {
/* names just stay as api ids */
}
return progress
.filter((a) => a.achieved === 1)
.sort((a, b) => b.unlocktime - a.unlocktime)
.slice(0, 8)
.map((a) => {
const meta = schema.get(a.apiname);
return {
platform: "steam" as const,
game: gameName,
name: meta?.displayName ?? a.apiname,
description: meta?.description,
icon: meta?.icon,
unlockedAt: a.unlocktime ? new Date(a.unlocktime * 1000).toISOString() : undefined,
};
});
}
export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "steam",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.apiKey || !cfg.steamId) {
return { ...base, error: "Steam API key and SteamID are required." };
}
const recent = await recentGames(cfg.apiKey, cfg.steamId);
const games: Game[] = recent.map((g) => ({
platform: "steam",
name: g.name,
image: header(g.appid),
url: `https://store.steampowered.com/app/${g.appid}`,
playtimeMinutes: g.playtime_forever,
}));
let achievements: Achievement[] = [];
if (recent[0]) {
achievements = await achievementsFor(
cfg.apiKey,
cfg.steamId,
recent[0].appid,
recent[0].name
);
}
return { ...base, games, achievements };
}
+118
View File
@@ -0,0 +1,118 @@
// Shared types for the integrations system. No server-only imports here so the
// shapes can be referenced from client components and serialized freely.
import type { SocialNetworkId } from "./social";
/** A curated link to a profile on an external network. */
export type SocialLink = {
network: SocialNetworkId;
url: string;
/** Optional override label; falls back to the network's display name. */
label?: string;
};
/** The platforms we can auto-fetch gameplay data from. */
export type PlatformId = "steam" | "psn" | "xbox";
/** A game surfaced from a platform's "recently played" / activity feed. */
export type Game = {
platform: PlatformId;
name: string;
/** Cover / header / icon image URL, if the platform gives us one. */
image?: string;
/** Link to the game/store/profile page. */
url?: string;
/** ISO timestamp of the last play session, if known. */
lastPlayed?: string;
/** Total playtime in minutes, if known. */
playtimeMinutes?: number;
};
/** A single unlocked (or notable) achievement / trophy. */
export type Achievement = {
platform: PlatformId;
/** Game the achievement belongs to. */
game: string;
name: string;
description?: string;
icon?: string;
/** ISO timestamp of unlock, if known. */
unlockedAt?: string;
/** PSN trophy type or rarity flavor, if known. */
rarity?: string;
};
/** A console the author owns / has owned (curated by hand). */
export type ConsoleItem = {
name: string;
note?: string;
};
/** An all-time favorite game (curated by hand). */
export type FavoriteGame = {
name: string;
note?: string;
};
/** Per-platform credential + toggle config. */
export type SteamConfig = {
enabled: boolean;
apiKey: string;
/** 64-bit SteamID. */
steamId: string;
};
export type PsnConfig = {
enabled: boolean;
/** NPSSO token extracted from a logged-in PSN web session. */
npsso: string;
};
export type XboxConfig = {
enabled: boolean;
/** OpenXBL (xbl.io) API key. */
apiKey: string;
/** Optional XUID; OpenXBL infers the authed account if blank. */
xuid: string;
};
/** Everything the admin curates + the platform credentials. Holds secrets. */
export type IntegrationConfig = {
displayName: string;
/** Markdown bio shown at the top of the profile. */
bio: string;
avatarUrl: string;
social: SocialLink[];
consoles: ConsoleItem[];
favorites: FavoriteGame[];
steam: SteamConfig;
psn: PsnConfig;
xbox: XboxConfig;
/** Cache freshness window for platform fetches, in minutes. */
cacheTtlMinutes: number;
};
/** Result of fetching one platform — always returned, never throws. */
export type PlatformData = {
platform: PlatformId;
games: Game[];
achievements: Achievement[];
/** Human-readable error if the last fetch failed (data may be stale/empty). */
error?: string;
/** ISO timestamp of when this payload was fetched. */
fetchedAt: string;
};
/** The fully-assembled profile rendered on the public bio page. */
export type Profile = {
displayName: string;
bioHtml: string;
avatarUrl: string;
social: SocialLink[];
consoles: ConsoleItem[];
favorites: FavoriteGame[];
games: Game[];
achievements: Achievement[];
/** Per-platform fetch status (for showing errors / freshness in admin). */
platforms: PlatformData[];
};
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import type { Achievement, Game, PlatformData, XboxConfig } from "./types";
// Xbox Live has no official public consumer API. We use OpenXBL (xbl.io), a
// popular third-party proxy: the user signs in there and pastes their API key.
// Docs: https://xbl.io/console
//
// Endpoints used:
// GET /api/v2/player/titleHistory -> recently played titles
// GET /api/v2/achievements -> recent achievement unlocks for the account
const API = "https://xbl.io/api/v2";
const TIMEOUT = 8000;
function headers(apiKey: string): HeadersInit {
return { "X-Authorization": apiKey, Accept: "application/json" };
}
async function getJson(path: string, apiKey: string): Promise<unknown> {
const res = await fetch(`${API}${path}`, {
headers: headers(apiKey),
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`OpenXBL ${res.status} ${res.statusText}`);
return res.json();
}
type TitleEntry = {
name?: string;
displayImage?: string;
titleHistory?: { lastTimePlayed?: string };
achievement?: { currentAchievements?: number };
};
type AchievementEntry = {
name?: string;
titleAssociations?: { name?: string }[];
description?: string;
unlockedDescription?: string;
progressState?: string;
progression?: { timeUnlocked?: string };
mediaAssets?: { name?: string; type?: string; url?: string }[];
rarity?: { currentPercentage?: number };
};
export async function fetchXbox(cfg: XboxConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "xbox",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.apiKey) return { ...base, error: "Xbox (OpenXBL) API key is required." };
// Recently played titles.
const history = (await getJson("/player/titleHistory", cfg.apiKey)) as {
titles?: TitleEntry[];
};
const games: Game[] = (history.titles ?? []).slice(0, 12).map((t) => ({
platform: "xbox",
name: t.name ?? "Unknown title",
image: t.displayImage,
lastPlayed: t.titleHistory?.lastTimePlayed,
}));
// Recent achievement unlocks.
let achievements: Achievement[] = [];
try {
const ach = (await getJson("/achievements", cfg.apiKey)) as {
achievements?: AchievementEntry[];
};
achievements = (ach.achievements ?? [])
.filter((a) => a.progressState === "Achieved")
.slice(0, 8)
.map((a) => ({
platform: "xbox" as const,
game: a.titleAssociations?.[0]?.name ?? "Xbox",
name: a.name ?? "Achievement",
description: a.description ?? a.unlockedDescription,
icon: a.mediaAssets?.find((m) => m.type === "Icon")?.url,
unlockedAt: a.progression?.timeUnlocked,
rarity:
typeof a.rarity?.currentPercentage === "number"
? `${a.rarity.currentPercentage}%`
: undefined,
}));
} catch {
/* achievements are optional; keep the games */
}
return { ...base, games, achievements };
}
+61 -3
View File
@@ -1,6 +1,8 @@
import "server-only";
import { marked } from "marked";
import { getDb } from "./db";
import { isSocialNetworkId } from "./integrations/social";
import type { SocialLink } from "./integrations/types";
export type Post = {
id: number;
@@ -10,6 +12,8 @@ export type Post = {
body: string;
author: string;
tags: string[];
/** Links to where this post was shared on social networks. */
links: SocialLink[];
createdAt: string;
};
@@ -21,9 +25,37 @@ type Row = {
body: string;
author: string;
tags: string;
links: string | null;
created_at: string;
};
// Parse the per-post links JSON, discarding anything malformed so a bad row can
// never break rendering.
function parseLinks(raw: string | null): SocialLink[] {
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr
.map((o): SocialLink | null => {
const network = (o as Record<string, unknown>)?.network;
const url = (o as Record<string, unknown>)?.url;
if (!isSocialNetworkId(network) || typeof url !== "string" || !url.trim()) {
return null;
}
const label = (o as Record<string, unknown>)?.label;
return {
network,
url: url.trim(),
label: typeof label === "string" && label.trim() ? label.trim() : undefined,
};
})
.filter((l): l is SocialLink => l !== null);
} catch {
return [];
}
}
function toPost(r: Row): Post {
return {
id: r.id,
@@ -33,6 +65,7 @@ function toPost(r: Row): Post {
body: r.body,
author: r.author,
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
links: parseLinks(r.links),
createdAt: r.created_at,
};
}
@@ -65,9 +98,32 @@ export type PostInput = {
body?: string;
author?: string;
tags?: string; // comma-separated
/** Social links, one per line as `network|url` or `network|url|label`. */
links?: string;
createdAt?: string;
};
// Turn the admin textarea (one `network|url|label` line each) into the JSON
// string stored in the posts.links column. Unknown networks / blank urls drop.
function serializeLinks(input: string | undefined): string {
if (!input) return "[]";
const out: SocialLink[] = [];
for (const line of input.split("\n")) {
const [network, url, ...rest] = line.split("|").map((s) => s.trim());
if (!isSocialNetworkId(network) || !url) continue;
const label = rest.join("|").trim();
out.push({ network, url, label: label || undefined });
}
return JSON.stringify(out);
}
/** Render a post's links back to the editable `network|url|label` text form. */
export function linksToText(links: SocialLink[]): string {
return links
.map((l) => [l.network, l.url, l.label].filter(Boolean).join(" | "))
.join("\n");
}
// Turn a title (or supplied slug) into a URL-safe, unique slug. When an id is
// given, that row is allowed to keep its own slug (used during edits).
export function slugify(input: string): string {
@@ -96,8 +152,8 @@ export function createPost(input: PostInput): Post {
const slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
const info = getDb()
.prepare(
`INSERT INTO posts (slug, title, excerpt, body, author, tags, created_at)
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @created_at)`
`INSERT INTO posts (slug, title, excerpt, body, author, tags, links, created_at)
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @links, @created_at)`
)
.run({
slug,
@@ -106,6 +162,7 @@ export function createPost(input: PostInput): Post {
body: input.body ?? "",
author: input.author?.trim() || "webmaster",
tags: normalizeTags(input.tags),
links: serializeLinks(input.links),
created_at: input.createdAt?.trim() || nowStamp(),
});
return getPostById(Number(info.lastInsertRowid))!;
@@ -123,7 +180,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
`UPDATE posts
SET slug = @slug, title = @title, excerpt = @excerpt,
body = @body, author = @author, tags = @tags,
created_at = @created_at
links = @links, created_at = @created_at
WHERE id = @id`
)
.run({
@@ -134,6 +191,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
body: input.body ?? "",
author: input.author?.trim() || "webmaster",
tags: normalizeTags(input.tags),
links: serializeLinks(input.links),
created_at: input.createdAt?.trim() || existing.createdAt,
});
return getPostById(id);