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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ↗
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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't be reached right now; showing the last
|
||||
known data.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user