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>
);