diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index ed4ab67..5b86a46 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -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 (
@@ -30,6 +35,19 @@ export default async function AboutPage() {
file and registering it — no markup changes required.
+
+ {showBio && cfg && (
+
+
About the webmaster
+
+ {cfg.displayName ? {cfg.displayName} : "The webmaster"}{" "}
+ keeps a living gamer profile here — recently played titles,
+ achievements, the console shelf, and all-time favorites.{" "}
+ See the full bio →
+
+
+
+ )}
);
diff --git a/src/app/admin/(panel)/integrations/page.tsx b/src/app/admin/(panel)/integrations/page.tsx
new file mode 100644
index 0000000..b63c7b1
--- /dev/null
+++ b/src/app/admin/(panel)/integrations/page.tsx
@@ -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 Integrations saved. Caches cleared.
;
+ if (refreshed) return Platform caches cleared — data refetches on next view.
;
+ 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 (
+
+
Integrations
+
+
+ Build your gamer bio: a profile, social links, console list, favorite
+ games, and live data pulled from gaming platforms. Everything here powers
+ the public bio page .
+
+
+ {/* ---- platform status ---- */}
+ {profile.platforms.length > 0 && (
+
+
Platform status
+
+
+
+ Platform
+ Games
+ Achievements
+ Fetched
+ Status
+
+
+
+ {profile.platforms.map((p) => (
+
+ {p.platform.toUpperCase()}
+ {p.games.length}
+ {p.achievements.length}
+ {fmtAge(p.fetchedAt)}
+
+ {p.error ? (
+
+ {p.error}
+
+ ) : (
+ "OK"
+ )}
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/admin/(panel)/layout.tsx b/src/app/admin/(panel)/layout.tsx
index e6afed7..6d86b56 100644
--- a/src/app/admin/(panel)/layout.tsx
+++ b/src/app/admin/(panel)/layout.tsx
@@ -21,6 +21,7 @@ export default async function PanelLayout({
Dashboard
Posts
+ Integrations
Settings
View site ↗
diff --git a/src/app/admin/(panel)/posts/PostForm.tsx b/src/app/admin/(panel)/posts/PostForm.tsx
index ed7cf92..eefd8f6 100644
--- a/src/app/admin/(panel)/posts/PostForm.tsx
+++ b/src/app/admin/(panel)/posts/PostForm.tsx
@@ -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({
/>
+
+
+ Shared on{" "}
+
+ (one per line: network|url|label — networks:{" "}
+ {SOCIAL_NETWORKS.map((n) => n.id).join(", ")})
+
+
+
+
+
{editing ? "Save changes" : "Create post"}
diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts
index d5f3bb1..f840508 100644
--- a/src/app/admin/actions.ts
+++ b/src/app/admin/actions.ts
@@ -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");
+}
diff --git a/src/app/admin/admin.css b/src/app/admin/admin.css
index f11890a..57ed41a 100644
--- a/src/app/admin/admin.css
+++ b/src/app/admin/admin.css
@@ -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;
+}
diff --git a/src/app/bio/page.tsx b/src/app/bio/page.tsx
new file mode 100644
index 0000000..d5de7e6
--- /dev/null
+++ b/src/app/bio/page.tsx
@@ -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
+
+ )}
+
+ {game.name}
+
+ {platformLabel(game.platform)}
+ {fmtPlaytime(game.playtimeMinutes) && (
+ {fmtPlaytime(game.playtimeMinutes)}
+ )}
+
+
+ >
+ );
+ return game.url ? (
+
+ {body}
+
+ ) : (
+ {body}
+ );
+}
+
+function AchievementRow({ a }: { a: Achievement }) {
+ return (
+
+ {a.icon ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ 🏆
+
+ )}
+
+ {a.name}
+
+ {a.game} · {platformLabel(a.platform)}
+ {a.rarity ? ` · ${a.rarity}` : ""}
+
+ {a.description && {a.description} }
+
+
+ );
+}
+
+export default async function BioPage() {
+ if (!hasIntegrations()) notFound();
+
+ const theme = await getTheme();
+ const profile = await getProfile();
+ const name = profile.displayName || "The Webmaster";
+
+ return (
+
+
+ {/* ---- header ---- */}
+
+
+ {/* ---- recent games ---- */}
+ {profile.games.length > 0 && (
+
+ Recently played
+
+ {profile.games.slice(0, 12).map((g, i) => (
+
+ ))}
+
+
+ )}
+
+ {/* ---- achievements ---- */}
+ {profile.achievements.length > 0 && (
+
+ Recent achievements
+
+ {profile.achievements.slice(0, 12).map((a, i) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* ---- favorites ---- */}
+ {profile.favorites.length > 0 && (
+
+ Favorite games
+
+ {profile.favorites.map((f, i) => (
+
+ {f.name}
+ {f.note && {f.note} }
+
+ ))}
+
+
+ )}
+
+ {/* ---- consoles ---- */}
+ {profile.consoles.length > 0 && (
+
+ Consoles
+
+ {profile.consoles.map((c, i) => (
+
+ {c.name}
+ {c.note && {c.note} }
+
+ ))}
+
+
+ )}
+
+
+ {/* ---- platform fetch errors (subtle) ---- */}
+ {profile.platforms.some((p) => p.error) && (
+
+ Some platforms couldn't be reached right now; showing the last
+ known data.
+
+ )}
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 94deaea..de22a8e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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; }
+}
diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx
index 85f08f3..0dcdd80 100644
--- a/src/app/posts/[slug]/page.tsx
+++ b/src/app/posts/[slug]/page.tsx
@@ -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 && (
+
+ )}
);
diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx
index 9a053bd..6e322a5 100644
--- a/src/components/Shell.tsx
+++ b/src/components/Shell.tsx
@@ -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({
About
+ {bioEnabled && (
+
+ Bio
+
+ )}
{admin && (
Admin
@@ -112,6 +123,14 @@ export default async function Shell({
+ {nowPlaying && (
+
+
+
+ Playing: {nowPlaying.name}
+
+
+ )}
diff --git a/src/components/SocialLinks.tsx b/src/components/SocialLinks.tsx
new file mode 100644
index 0000000..27cfb66
--- /dev/null
+++ b/src/components/SocialLinks.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index a375f9e..130de8c 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -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) {
diff --git a/src/lib/integrations/cache.ts b/src/lib/integrations/cache.ts
new file mode 100644
index 0000000..a5f0cef
--- /dev/null
+++ b/src/lib/integrations/cache.ts
@@ -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 = { 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(
+ key: string,
+ ttlMinutes: number,
+ fallback: T,
+ fetcher: () => Promise
+): Promise> {
+ 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);
+}
diff --git a/src/lib/integrations/config.ts b/src/lib/integrations/config.ts
new file mode 100644
index 0000000..991ad7a
--- /dev/null
+++ b/src/lib/integrations/config.ts
@@ -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;
+ 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;
+ 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;
+ 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;
+ const steam = (o.steam ?? {}) as Record;
+ const psn = (o.psn ?? {}) as Record;
+ const xbox = (o.xbox ?? {}) as Record;
+ 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;
+}
diff --git a/src/lib/integrations/index.ts b/src/lib/integrations/index.ts
new file mode 100644
index 0000000..ef222f2
--- /dev/null
+++ b/src/lib/integrations/index.ts
@@ -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 = {
+ 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 {
+ const ttl = cfg.cacheTtlMinutes;
+ const key = CACHE_KEYS[platform];
+ const fallback = emptyPlatform(platform);
+ let result: Cached;
+ 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 {
+ 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 {
+ 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));
+}
diff --git a/src/lib/integrations/psn.ts b/src/lib/integrations/psn.ts
new file mode 100644
index 0000000..a74b51b
--- /dev/null
+++ b/src/lib/integrations/psn.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/src/lib/integrations/social.ts b/src/lib/integrations/social.ts
new file mode 100644
index 0000000..c1a3d44
--- /dev/null
+++ b/src/lib/integrations/social.ts
@@ -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: "🌐" };
+}
diff --git a/src/lib/integrations/steam.ts b/src/lib/integrations/steam.ts
new file mode 100644
index 0000000..cc0b62c
--- /dev/null
+++ b/src/lib/integrations/steam.ts
@@ -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 {
+ 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 {
+ 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 {
+ // 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();
+ 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 {
+ 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 };
+}
diff --git a/src/lib/integrations/types.ts b/src/lib/integrations/types.ts
new file mode 100644
index 0000000..a6800e0
--- /dev/null
+++ b/src/lib/integrations/types.ts
@@ -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[];
+};
diff --git a/src/lib/integrations/xbox.ts b/src/lib/integrations/xbox.ts
new file mode 100644
index 0000000..446a321
--- /dev/null
+++ b/src/lib/integrations/xbox.ts
@@ -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 {
+ 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 {
+ 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 };
+}
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
index dbfc720..d5c3114 100644
--- a/src/lib/posts.ts
+++ b/src/lib/posts.ts
@@ -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)?.network;
+ const url = (o as Record)?.url;
+ if (!isSocialNetworkId(network) || typeof url !== "string" || !url.trim()) {
+ return null;
+ }
+ const label = (o as Record)?.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);