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 Shell from "@/components/Shell";
|
||||||
|
import SocialLinks from "@/components/SocialLinks";
|
||||||
import { getTheme } from "@/themes/server";
|
import { getTheme } from "@/themes/server";
|
||||||
import { THEMES } from "@/themes/registry";
|
import { THEMES } from "@/themes/registry";
|
||||||
|
import { getIntegrationConfig, hasIntegrations } from "@/lib/integrations";
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
const theme = await getTheme();
|
const theme = await getTheme();
|
||||||
|
const showBio = hasIntegrations();
|
||||||
|
const cfg = showBio ? getIntegrationConfig() : null;
|
||||||
return (
|
return (
|
||||||
<Shell theme={theme} title="About">
|
<Shell theme={theme} title="About">
|
||||||
<article className="rb-article">
|
<article className="rb-article">
|
||||||
@@ -30,6 +35,19 @@ export default async function AboutPage() {
|
|||||||
file and registering it — no markup changes required.
|
file and registering it — no markup changes required.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
</Shell>
|
</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">
|
<nav className="rb-admin-nav">
|
||||||
<Link href="/admin">Dashboard</Link>
|
<Link href="/admin">Dashboard</Link>
|
||||||
<Link href="/admin/posts">Posts</Link>
|
<Link href="/admin/posts">Posts</Link>
|
||||||
|
<Link href="/admin/integrations">Integrations</Link>
|
||||||
<Link href="/admin/settings">Settings</Link>
|
<Link href="/admin/settings">Settings</Link>
|
||||||
<Link href="/" target="_blank">
|
<Link href="/" target="_blank">
|
||||||
View site ↗
|
View site ↗
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Post } from "@/lib/posts";
|
import type { Post } from "@/lib/posts";
|
||||||
|
import { linksToText } from "@/lib/posts";
|
||||||
|
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
|
||||||
import { savePostAction } from "../../actions";
|
import { savePostAction } from "../../actions";
|
||||||
|
|
||||||
// Server-rendered create/edit form. Both modes post to the same action; the
|
// Server-rendered create/edit form. Both modes post to the same action; the
|
||||||
@@ -80,6 +82,23 @@ export default function PostForm({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<div className="rb-form-actions">
|
||||||
<button className="rb-btn rb-btn-primary" type="submit">
|
<button className="rb-btn rb-btn-primary" type="submit">
|
||||||
{editing ? "Save changes" : "Create post"}
|
{editing ? "Save changes" : "Create post"}
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ import {
|
|||||||
normalizeSettings,
|
normalizeSettings,
|
||||||
type Settings,
|
type Settings,
|
||||||
} from "@/lib/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";
|
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
|
||||||
|
|
||||||
function s(formData: FormData, key: string): string {
|
function s(formData: FormData, key: string): string {
|
||||||
@@ -70,6 +82,7 @@ function postInputFromForm(formData: FormData): PostInput {
|
|||||||
body: s(formData, "body"),
|
body: s(formData, "body"),
|
||||||
author: s(formData, "author"),
|
author: s(formData, "author"),
|
||||||
tags: s(formData, "tags"),
|
tags: s(formData, "tags"),
|
||||||
|
links: s(formData, "links"),
|
||||||
createdAt: s(formData, "createdAt"),
|
createdAt: s(formData, "createdAt"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -183,3 +196,70 @@ export async function importAction(formData: FormData) {
|
|||||||
revalidateSite();
|
revalidateSite();
|
||||||
redirect("/admin/settings?import=ok");
|
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 {
|
.rb-login-card .rb-admin-h1 {
|
||||||
margin-bottom: 6px;
|
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 {
|
:root {
|
||||||
color-scheme: light;
|
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 Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Shell from "@/components/Shell";
|
import Shell from "@/components/Shell";
|
||||||
|
import SocialLinks from "@/components/SocialLinks";
|
||||||
import { getTheme } from "@/themes/server";
|
import { getTheme } from "@/themes/server";
|
||||||
import {
|
import {
|
||||||
getAllPosts,
|
getAllPosts,
|
||||||
@@ -47,6 +48,12 @@ export default async function PostPage({
|
|||||||
className="rb-prose"
|
className="rb-prose"
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
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>
|
</article>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode } from "react";
|
|||||||
import { THEMES, type ThemeId } from "@/themes/registry";
|
import { THEMES, type ThemeId } from "@/themes/registry";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { isAdmin } from "@/lib/auth";
|
import { isAdmin } from "@/lib/auth";
|
||||||
|
import { getNowPlaying, hasIntegrations } from "@/lib/integrations";
|
||||||
import ThemeSwitcher from "./ThemeSwitcher";
|
import ThemeSwitcher from "./ThemeSwitcher";
|
||||||
import Clock from "./Clock";
|
import Clock from "./Clock";
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ export default async function Shell({
|
|||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const admin = await isAdmin();
|
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
|
// Admins always get the switcher with every skin; the public only sees it when
|
||||||
// enabled, and only the allowed skins.
|
// enabled, and only the allowed skins.
|
||||||
const showSwitcher = admin || settings.publicThemeToggle;
|
const showSwitcher = admin || settings.publicThemeToggle;
|
||||||
@@ -79,6 +85,11 @@ export default async function Shell({
|
|||||||
<Link className="rb-menu-link" href="/about">
|
<Link className="rb-menu-link" href="/about">
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
|
{bioEnabled && (
|
||||||
|
<Link className="rb-menu-link" href="/bio">
|
||||||
|
Bio
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{admin && (
|
{admin && (
|
||||||
<Link className="rb-menu-link" href="/admin">
|
<Link className="rb-menu-link" href="/admin">
|
||||||
Admin
|
Admin
|
||||||
@@ -112,6 +123,14 @@ export default async function Shell({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rb-tray">
|
<div className="rb-tray">
|
||||||
|
{nowPlaying && (
|
||||||
|
<Link className="rb-nowplaying" href="/bio" title="Recently played">
|
||||||
|
<span className="rb-nowplaying-dot" aria-hidden />
|
||||||
|
<span className="rb-nowplaying-label">
|
||||||
|
Playing: <strong>{nowPlaying.name}</strong>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Clock />
|
<Clock />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { socialNetwork } from "@/lib/integrations/social";
|
||||||
|
import type { SocialLink } from "@/lib/integrations/types";
|
||||||
|
|
||||||
|
// Theme-agnostic row of social links. Reused for the curated profile links and
|
||||||
|
// for per-post "shared on" links. Styling lives in the `rb-social*` classes.
|
||||||
|
export default function SocialLinks({
|
||||||
|
links,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
links: SocialLink[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (links.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className={`rb-social ${className}`.trim()}>
|
||||||
|
{links.map((l, i) => {
|
||||||
|
const net = socialNetwork(l.network);
|
||||||
|
return (
|
||||||
|
<li key={`${l.network}-${i}`}>
|
||||||
|
<a
|
||||||
|
className="rb-social-link"
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="me noreferrer"
|
||||||
|
>
|
||||||
|
<span className="rb-social-icon" aria-hidden>
|
||||||
|
{net.icon}
|
||||||
|
</span>
|
||||||
|
<span className="rb-social-name">{l.label ?? net.name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,32 @@ function migrate(db: Database.Database) {
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
data TEXT NOT NULL
|
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) {
|
function seed(db: Database.Database) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
|
||||||
|
// Lazy TTL cache for external platform fetches. Each platform stores one row.
|
||||||
|
// On a fresh hit we return the cached payload; when stale we run the fetcher,
|
||||||
|
// persist the result, and — crucially — fall back to the last good payload if
|
||||||
|
// the fetcher fails, so a transient API outage never blanks the bio page.
|
||||||
|
|
||||||
|
type CacheRow = {
|
||||||
|
data: string;
|
||||||
|
error: string | null;
|
||||||
|
fetched_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function read(key: string): CacheRow | undefined {
|
||||||
|
return getDb()
|
||||||
|
.prepare("SELECT data, error, fetched_at FROM integration_cache WHERE key = ?")
|
||||||
|
.get(key) as CacheRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key: string, data: string, error: string | null): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO integration_cache (key, data, error, fetched_at)
|
||||||
|
VALUES (@key, @data, @error, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET data = @data, error = @error,
|
||||||
|
fetched_at = datetime('now')`
|
||||||
|
)
|
||||||
|
.run({ key, data, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageMinutes(iso: string): number {
|
||||||
|
const t = new Date(iso.replace(" ", "T") + "Z").getTime();
|
||||||
|
if (Number.isNaN(t)) return Infinity;
|
||||||
|
return (Date.now() - t) / 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Cached<T> = { data: T; fetchedAt: string; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return cached `key` data if younger than `ttlMinutes`, otherwise refetch.
|
||||||
|
* The fetcher itself should never throw (catch internally) but we guard anyway.
|
||||||
|
*/
|
||||||
|
export async function cached<T>(
|
||||||
|
key: string,
|
||||||
|
ttlMinutes: number,
|
||||||
|
fallback: T,
|
||||||
|
fetcher: () => Promise<T>
|
||||||
|
): Promise<Cached<T>> {
|
||||||
|
const row = read(key);
|
||||||
|
if (row && ageMinutes(row.fetched_at) < ttlMinutes) {
|
||||||
|
try {
|
||||||
|
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: row.error ?? undefined };
|
||||||
|
} catch {
|
||||||
|
/* fall through to refetch on corrupt cache */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fresh = await fetcher();
|
||||||
|
write(key, JSON.stringify(fresh), null);
|
||||||
|
return { data: fresh, fetchedAt: new Date().toISOString() };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
// Keep the previous good payload visible; just surface the error.
|
||||||
|
if (row) {
|
||||||
|
try {
|
||||||
|
write(key, row.data, msg);
|
||||||
|
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: msg };
|
||||||
|
} catch {
|
||||||
|
/* corrupt previous payload — fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(key, JSON.stringify(fallback), msg);
|
||||||
|
return { data: fallback, fetchedAt: new Date().toISOString(), error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force the next read of these keys to refetch by deleting their rows. */
|
||||||
|
export function clearCache(keys?: string[]): void {
|
||||||
|
const db = getDb();
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
db.prepare("DELETE FROM integration_cache").run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stmt = db.prepare("DELETE FROM integration_cache WHERE key = ?");
|
||||||
|
const tx = db.transaction((ks: string[]) => ks.forEach((k) => stmt.run(k)));
|
||||||
|
tx(keys);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
import { isSocialNetworkId, type SocialNetworkId } from "./social";
|
||||||
|
import type {
|
||||||
|
ConsoleItem,
|
||||||
|
FavoriteGame,
|
||||||
|
IntegrationConfig,
|
||||||
|
SocialLink,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Integrations config persisted as a single JSON row, mirroring the settings
|
||||||
|
// module. Holds platform credentials (secrets) alongside curated profile
|
||||||
|
// content, so it is deliberately NOT part of the export/import surface.
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: IntegrationConfig = {
|
||||||
|
displayName: "",
|
||||||
|
bio: "",
|
||||||
|
avatarUrl: "",
|
||||||
|
social: [],
|
||||||
|
consoles: [],
|
||||||
|
favorites: [],
|
||||||
|
steam: { enabled: false, apiKey: "", steamId: "" },
|
||||||
|
psn: { enabled: false, npsso: "" },
|
||||||
|
xbox: { enabled: false, apiKey: "", xuid: "" },
|
||||||
|
cacheTtlMinutes: 360,
|
||||||
|
};
|
||||||
|
|
||||||
|
function str(v: unknown, fallback = ""): string {
|
||||||
|
return typeof v === "string" ? v : fallback;
|
||||||
|
}
|
||||||
|
function bool(v: unknown, fallback = false): boolean {
|
||||||
|
return typeof v === "boolean" ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normSocial(input: unknown): SocialLink[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const out: SocialLink[] = [];
|
||||||
|
for (const raw of input) {
|
||||||
|
const o = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const network = o.network;
|
||||||
|
const url = str(o.url).trim();
|
||||||
|
if (!isSocialNetworkId(network) || !url) continue;
|
||||||
|
out.push({
|
||||||
|
network: network as SocialNetworkId,
|
||||||
|
url,
|
||||||
|
label: str(o.label).trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normConsoles(input: unknown): ConsoleItem[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
return input
|
||||||
|
.map((raw) => {
|
||||||
|
const o = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
|
||||||
|
})
|
||||||
|
.filter((c) => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normFavorites(input: unknown): FavoriteGame[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
return input
|
||||||
|
.map((raw) => {
|
||||||
|
const o = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
|
||||||
|
})
|
||||||
|
.filter((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConfig(input: unknown): IntegrationConfig {
|
||||||
|
const o = (input ?? {}) as Record<string, unknown>;
|
||||||
|
const steam = (o.steam ?? {}) as Record<string, unknown>;
|
||||||
|
const psn = (o.psn ?? {}) as Record<string, unknown>;
|
||||||
|
const xbox = (o.xbox ?? {}) as Record<string, unknown>;
|
||||||
|
const ttl = Number(o.cacheTtlMinutes);
|
||||||
|
return {
|
||||||
|
displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
|
||||||
|
bio: str(o.bio, DEFAULT_CONFIG.bio),
|
||||||
|
avatarUrl: str(o.avatarUrl, DEFAULT_CONFIG.avatarUrl).trim(),
|
||||||
|
social: normSocial(o.social),
|
||||||
|
consoles: normConsoles(o.consoles),
|
||||||
|
favorites: normFavorites(o.favorites),
|
||||||
|
steam: {
|
||||||
|
enabled: bool(steam.enabled),
|
||||||
|
apiKey: str(steam.apiKey).trim(),
|
||||||
|
steamId: str(steam.steamId).trim(),
|
||||||
|
},
|
||||||
|
psn: {
|
||||||
|
enabled: bool(psn.enabled),
|
||||||
|
npsso: str(psn.npsso).trim(),
|
||||||
|
},
|
||||||
|
xbox: {
|
||||||
|
enabled: bool(xbox.enabled),
|
||||||
|
apiKey: str(xbox.apiKey).trim(),
|
||||||
|
xuid: str(xbox.xuid).trim(),
|
||||||
|
},
|
||||||
|
cacheTtlMinutes:
|
||||||
|
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntegrationConfig(): IntegrationConfig {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare("SELECT data FROM integrations WHERE id = 1")
|
||||||
|
.get() as { data: string } | undefined;
|
||||||
|
if (!row) return DEFAULT_CONFIG;
|
||||||
|
try {
|
||||||
|
return normalizeConfig(JSON.parse(row.data));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveIntegrationConfig(next: IntegrationConfig): IntegrationConfig {
|
||||||
|
const clean = normalizeConfig(next);
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO integrations (id, data) VALUES (1, @data)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET data = @data`
|
||||||
|
)
|
||||||
|
.run({ data: JSON.stringify(clean) });
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { cached, clearCache, type Cached } from "./cache";
|
||||||
|
import { getIntegrationConfig } from "./config";
|
||||||
|
import { fetchSteam } from "./steam";
|
||||||
|
import { fetchPsn } from "./psn";
|
||||||
|
import { fetchXbox } from "./xbox";
|
||||||
|
import type {
|
||||||
|
Game,
|
||||||
|
IntegrationConfig,
|
||||||
|
PlatformData,
|
||||||
|
PlatformId,
|
||||||
|
Profile,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { getIntegrationConfig, saveIntegrationConfig, DEFAULT_CONFIG } from "./config";
|
||||||
|
export type { IntegrationConfig } from "./types";
|
||||||
|
|
||||||
|
const CACHE_KEYS: Record<PlatformId, string> = {
|
||||||
|
steam: "platform:steam",
|
||||||
|
psn: "platform:psn",
|
||||||
|
xbox: "platform:xbox",
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyPlatform(platform: PlatformId): PlatformData {
|
||||||
|
return { platform, games: [], achievements: [], fetchedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch one platform through the TTL cache. */
|
||||||
|
async function loadPlatform(
|
||||||
|
platform: PlatformId,
|
||||||
|
cfg: IntegrationConfig
|
||||||
|
): Promise<PlatformData> {
|
||||||
|
const ttl = cfg.cacheTtlMinutes;
|
||||||
|
const key = CACHE_KEYS[platform];
|
||||||
|
const fallback = emptyPlatform(platform);
|
||||||
|
let result: Cached<PlatformData>;
|
||||||
|
switch (platform) {
|
||||||
|
case "steam":
|
||||||
|
result = await cached(key, ttl, fallback, () => fetchSteam(cfg.steam));
|
||||||
|
break;
|
||||||
|
case "psn":
|
||||||
|
result = await cached(key, ttl, fallback, () => fetchPsn(cfg.psn));
|
||||||
|
break;
|
||||||
|
case "xbox":
|
||||||
|
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Surface a cache-layer error onto the payload if the fetch's own error is unset.
|
||||||
|
return result.error && !result.data.error
|
||||||
|
? { ...result.data, error: result.error, fetchedAt: result.fetchedAt }
|
||||||
|
: { ...result.data, fetchedAt: result.fetchedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
|
||||||
|
const out: PlatformId[] = [];
|
||||||
|
if (cfg.steam.enabled) out.push("steam");
|
||||||
|
if (cfg.psn.enabled) out.push("psn");
|
||||||
|
if (cfg.xbox.enabled) out.push("xbox");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameTime(g: Game): number {
|
||||||
|
return g.lastPlayed ? new Date(g.lastPlayed).getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assemble the full public profile: curated content + cached platform data. */
|
||||||
|
export async function getProfile(): Promise<Profile> {
|
||||||
|
const cfg = getIntegrationConfig();
|
||||||
|
const platforms = await Promise.all(
|
||||||
|
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
|
||||||
|
);
|
||||||
|
|
||||||
|
const games = platforms
|
||||||
|
.flatMap((p) => p.games)
|
||||||
|
.sort((a, b) => gameTime(b) - gameTime(a));
|
||||||
|
const achievements = platforms
|
||||||
|
.flatMap((p) => p.achievements)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.unlockedAt ?? 0).getTime() - new Date(a.unlockedAt ?? 0).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: cfg.displayName,
|
||||||
|
bioHtml: cfg.bio ? (marked.parse(cfg.bio, { async: false }) as string) : "",
|
||||||
|
avatarUrl: cfg.avatarUrl,
|
||||||
|
social: cfg.social,
|
||||||
|
consoles: cfg.consoles,
|
||||||
|
favorites: cfg.favorites,
|
||||||
|
games,
|
||||||
|
achievements,
|
||||||
|
platforms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest game across all enabled platforms — for the persistent shell widget. */
|
||||||
|
export async function getNowPlaying(): Promise<Game | null> {
|
||||||
|
const cfg = getIntegrationConfig();
|
||||||
|
if (enabledPlatforms(cfg).length === 0) return null;
|
||||||
|
const platforms = await Promise.all(
|
||||||
|
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
|
||||||
|
);
|
||||||
|
const games = platforms.flatMap((p) => p.games);
|
||||||
|
if (games.length === 0) return null;
|
||||||
|
// Prefer most-recently-played; platforms without timestamps fall to list order.
|
||||||
|
const timed = games.filter((g) => g.lastPlayed);
|
||||||
|
if (timed.length) return timed.sort((a, b) => gameTime(b) - gameTime(a))[0];
|
||||||
|
return games[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if any platform integration is switched on (gates the public bio link). */
|
||||||
|
export function hasIntegrations(): boolean {
|
||||||
|
const cfg = getIntegrationConfig();
|
||||||
|
return (
|
||||||
|
enabledPlatforms(cfg).length > 0 ||
|
||||||
|
cfg.social.length > 0 ||
|
||||||
|
cfg.consoles.length > 0 ||
|
||||||
|
cfg.favorites.length > 0 ||
|
||||||
|
cfg.bio.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop cached platform payloads so the next read refetches. */
|
||||||
|
export function refreshPlatforms(): void {
|
||||||
|
clearCache(Object.values(CACHE_KEYS));
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Achievement, Game, PlatformData, PsnConfig } from "./types";
|
||||||
|
|
||||||
|
// PlayStation Network has no official public API. We replicate the well-known
|
||||||
|
// NPSSO -> authorization-code -> access-token exchange (the same flow the
|
||||||
|
// `psn-api` library performs) using raw fetch, then read the user's trophy
|
||||||
|
// titles. This is ToS-gray and brittle by nature; every step degrades to a
|
||||||
|
// thrown Error which the cache layer turns into a surfaced status, never a crash.
|
||||||
|
|
||||||
|
const AUTH_BASE = "https://ca.account.sony.com/api/authz/v3/oauth";
|
||||||
|
const API_BASE = "https://m.np.playstation.com/api";
|
||||||
|
const CLIENT_ID = "09515159-7237-4370-9b40-3806e67c0891";
|
||||||
|
const REDIRECT = "com.scee.psxandroid.scecompcall://redirect";
|
||||||
|
// Public basic-auth pair used by the PSN mobile client (client_id:client_secret).
|
||||||
|
const BASIC = "MDk1MTUxNTktNzIzNy00MzcwLTliNDAtMzgwNmU2N2MwODkxOnVjUGprYTV0bnRCMktxc1A=";
|
||||||
|
const TIMEOUT = 8000;
|
||||||
|
|
||||||
|
/** NPSSO cookie -> short-lived authorization code. */
|
||||||
|
async function exchangeCode(npsso: string): Promise<string> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
access_type: "offline",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "psn:mobile.v2.core psn:clientapp",
|
||||||
|
});
|
||||||
|
const res = await fetch(`${AUTH_BASE}/authorize?${params}`, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
headers: { Cookie: `npsso=${npsso}` },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT),
|
||||||
|
});
|
||||||
|
const location = res.headers.get("location") ?? "";
|
||||||
|
const code = new URL(location, "https://ca.account.sony.com").searchParams.get("code");
|
||||||
|
if (!code) throw new Error("PSN: could not obtain auth code (NPSSO expired or invalid).");
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization code -> access token. */
|
||||||
|
async function exchangeToken(code: string): Promise<string> {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${BASIC}`,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
redirect_uri: REDIRECT,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
token_format: "jwt",
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`PSN token exchange failed (${res.status}).`);
|
||||||
|
const json = (await res.json()) as { access_token?: string };
|
||||||
|
if (!json.access_token) throw new Error("PSN: no access token returned.");
|
||||||
|
return json.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrophyTitle = {
|
||||||
|
npCommunicationId: string;
|
||||||
|
trophyTitleName: string;
|
||||||
|
trophyTitleIconUrl?: string;
|
||||||
|
trophyTitlePlatform?: string;
|
||||||
|
lastUpdatedDateTime?: string;
|
||||||
|
earnedTrophies?: { bronze: number; silver: number; gold: number; platinum: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function trophyTitles(token: string): Promise<TrophyTitle[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/trophy/v1/users/me/trophyTitles?limit=16`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`PSN trophyTitles failed (${res.status}).`);
|
||||||
|
const json = (await res.json()) as { trophyTitles?: TrophyTitle[] };
|
||||||
|
return json.trophyTitles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPsn(cfg: PsnConfig): Promise<PlatformData> {
|
||||||
|
const base: PlatformData = {
|
||||||
|
platform: "psn",
|
||||||
|
games: [],
|
||||||
|
achievements: [],
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (!cfg.enabled) return base;
|
||||||
|
if (!cfg.npsso) return { ...base, error: "PSN NPSSO token is required." };
|
||||||
|
|
||||||
|
const token = await exchangeToken(await exchangeCode(cfg.npsso));
|
||||||
|
const titles = await trophyTitles(token);
|
||||||
|
|
||||||
|
const sorted = [...titles].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.lastUpdatedDateTime ?? 0).getTime() -
|
||||||
|
new Date(a.lastUpdatedDateTime ?? 0).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const games: Game[] = sorted.map((t) => ({
|
||||||
|
platform: "psn",
|
||||||
|
name: t.trophyTitleName,
|
||||||
|
image: t.trophyTitleIconUrl,
|
||||||
|
lastPlayed: t.lastUpdatedDateTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Surface platinum trophies as headline achievements (the API does not return
|
||||||
|
// individual trophy unlocks without a per-title call; platinums are the prize).
|
||||||
|
const achievements: Achievement[] = sorted
|
||||||
|
.filter((t) => (t.earnedTrophies?.platinum ?? 0) > 0)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((t) => ({
|
||||||
|
platform: "psn" as const,
|
||||||
|
game: t.trophyTitleName,
|
||||||
|
name: "Platinum Trophy",
|
||||||
|
icon: t.trophyTitleIconUrl,
|
||||||
|
unlockedAt: t.lastUpdatedDateTime,
|
||||||
|
rarity: "platinum",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ...base, games, achievements };
|
||||||
|
}
|
||||||
@@ -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: "🌐" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Achievement, Game, PlatformData, SteamConfig } from "./types";
|
||||||
|
|
||||||
|
// Steam Web API. The only platform here with a sane, official, key-based API.
|
||||||
|
// Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API
|
||||||
|
//
|
||||||
|
// We pull the recently-played games, then fetch unlocked achievements (with
|
||||||
|
// human names from the game schema) for the single most-recent title to keep
|
||||||
|
// the request count bounded.
|
||||||
|
|
||||||
|
const API = "https://api.steampowered.com";
|
||||||
|
const TIMEOUT = 8000;
|
||||||
|
|
||||||
|
function header(appid: number): string {
|
||||||
|
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(url: string): Promise<unknown> {
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
|
||||||
|
if (!res.ok) throw new Error(`Steam API ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentGame = {
|
||||||
|
appid: number;
|
||||||
|
name: string;
|
||||||
|
playtime_forever?: number;
|
||||||
|
playtime_2weeks?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function recentGames(key: string, steamId: string): Promise<RecentGame[]> {
|
||||||
|
const url = `${API}/IPlayerService/GetRecentlyPlayedGames/v1/?key=${key}&steamid=${steamId}&count=12&format=json`;
|
||||||
|
const json = (await getJson(url)) as { response?: { games?: RecentGame[] } };
|
||||||
|
return json.response?.games ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string };
|
||||||
|
|
||||||
|
async function achievementsFor(
|
||||||
|
key: string,
|
||||||
|
steamId: string,
|
||||||
|
appid: number,
|
||||||
|
gameName: string
|
||||||
|
): Promise<Achievement[]> {
|
||||||
|
// Player progress.
|
||||||
|
const progUrl = `${API}/ISteamUserStats/GetPlayerAchievements/v1/?key=${key}&steamid=${steamId}&appid=${appid}&l=en`;
|
||||||
|
let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
|
||||||
|
try {
|
||||||
|
const json = (await getJson(progUrl)) as {
|
||||||
|
playerstats?: { achievements?: typeof progress };
|
||||||
|
};
|
||||||
|
progress = json.playerstats?.achievements ?? [];
|
||||||
|
} catch {
|
||||||
|
return []; // many games expose no achievements; treat as none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names + icons come from the game schema.
|
||||||
|
const schemaUrl = `${API}/ISteamUserStats/GetSchemaForGame/v2/?key=${key}&appid=${appid}&l=en`;
|
||||||
|
const schema = new Map<string, SchemaAch>();
|
||||||
|
try {
|
||||||
|
const json = (await getJson(schemaUrl)) as {
|
||||||
|
game?: { availableGameStats?: { achievements?: SchemaAch[] } };
|
||||||
|
};
|
||||||
|
for (const a of json.game?.availableGameStats?.achievements ?? []) {
|
||||||
|
schema.set(a.name, a);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* names just stay as api ids */
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress
|
||||||
|
.filter((a) => a.achieved === 1)
|
||||||
|
.sort((a, b) => b.unlocktime - a.unlocktime)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((a) => {
|
||||||
|
const meta = schema.get(a.apiname);
|
||||||
|
return {
|
||||||
|
platform: "steam" as const,
|
||||||
|
game: gameName,
|
||||||
|
name: meta?.displayName ?? a.apiname,
|
||||||
|
description: meta?.description,
|
||||||
|
icon: meta?.icon,
|
||||||
|
unlockedAt: a.unlocktime ? new Date(a.unlocktime * 1000).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
|
||||||
|
const base: PlatformData = {
|
||||||
|
platform: "steam",
|
||||||
|
games: [],
|
||||||
|
achievements: [],
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (!cfg.enabled) return base;
|
||||||
|
if (!cfg.apiKey || !cfg.steamId) {
|
||||||
|
return { ...base, error: "Steam API key and SteamID are required." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = await recentGames(cfg.apiKey, cfg.steamId);
|
||||||
|
const games: Game[] = recent.map((g) => ({
|
||||||
|
platform: "steam",
|
||||||
|
name: g.name,
|
||||||
|
image: header(g.appid),
|
||||||
|
url: `https://store.steampowered.com/app/${g.appid}`,
|
||||||
|
playtimeMinutes: g.playtime_forever,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let achievements: Achievement[] = [];
|
||||||
|
if (recent[0]) {
|
||||||
|
achievements = await achievementsFor(
|
||||||
|
cfg.apiKey,
|
||||||
|
cfg.steamId,
|
||||||
|
recent[0].appid,
|
||||||
|
recent[0].name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, games, achievements };
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Achievement, Game, PlatformData, XboxConfig } from "./types";
|
||||||
|
|
||||||
|
// Xbox Live has no official public consumer API. We use OpenXBL (xbl.io), a
|
||||||
|
// popular third-party proxy: the user signs in there and pastes their API key.
|
||||||
|
// Docs: https://xbl.io/console
|
||||||
|
//
|
||||||
|
// Endpoints used:
|
||||||
|
// GET /api/v2/player/titleHistory -> recently played titles
|
||||||
|
// GET /api/v2/achievements -> recent achievement unlocks for the account
|
||||||
|
|
||||||
|
const API = "https://xbl.io/api/v2";
|
||||||
|
const TIMEOUT = 8000;
|
||||||
|
|
||||||
|
function headers(apiKey: string): HeadersInit {
|
||||||
|
return { "X-Authorization": apiKey, Accept: "application/json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(path: string, apiKey: string): Promise<unknown> {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
headers: headers(apiKey),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`OpenXBL ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
type TitleEntry = {
|
||||||
|
name?: string;
|
||||||
|
displayImage?: string;
|
||||||
|
titleHistory?: { lastTimePlayed?: string };
|
||||||
|
achievement?: { currentAchievements?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type AchievementEntry = {
|
||||||
|
name?: string;
|
||||||
|
titleAssociations?: { name?: string }[];
|
||||||
|
description?: string;
|
||||||
|
unlockedDescription?: string;
|
||||||
|
progressState?: string;
|
||||||
|
progression?: { timeUnlocked?: string };
|
||||||
|
mediaAssets?: { name?: string; type?: string; url?: string }[];
|
||||||
|
rarity?: { currentPercentage?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchXbox(cfg: XboxConfig): Promise<PlatformData> {
|
||||||
|
const base: PlatformData = {
|
||||||
|
platform: "xbox",
|
||||||
|
games: [],
|
||||||
|
achievements: [],
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (!cfg.enabled) return base;
|
||||||
|
if (!cfg.apiKey) return { ...base, error: "Xbox (OpenXBL) API key is required." };
|
||||||
|
|
||||||
|
// Recently played titles.
|
||||||
|
const history = (await getJson("/player/titleHistory", cfg.apiKey)) as {
|
||||||
|
titles?: TitleEntry[];
|
||||||
|
};
|
||||||
|
const games: Game[] = (history.titles ?? []).slice(0, 12).map((t) => ({
|
||||||
|
platform: "xbox",
|
||||||
|
name: t.name ?? "Unknown title",
|
||||||
|
image: t.displayImage,
|
||||||
|
lastPlayed: t.titleHistory?.lastTimePlayed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Recent achievement unlocks.
|
||||||
|
let achievements: Achievement[] = [];
|
||||||
|
try {
|
||||||
|
const ach = (await getJson("/achievements", cfg.apiKey)) as {
|
||||||
|
achievements?: AchievementEntry[];
|
||||||
|
};
|
||||||
|
achievements = (ach.achievements ?? [])
|
||||||
|
.filter((a) => a.progressState === "Achieved")
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((a) => ({
|
||||||
|
platform: "xbox" as const,
|
||||||
|
game: a.titleAssociations?.[0]?.name ?? "Xbox",
|
||||||
|
name: a.name ?? "Achievement",
|
||||||
|
description: a.description ?? a.unlockedDescription,
|
||||||
|
icon: a.mediaAssets?.find((m) => m.type === "Icon")?.url,
|
||||||
|
unlockedAt: a.progression?.timeUnlocked,
|
||||||
|
rarity:
|
||||||
|
typeof a.rarity?.currentPercentage === "number"
|
||||||
|
? `${a.rarity.currentPercentage}%`
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
/* achievements are optional; keep the games */
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, games, achievements };
|
||||||
|
}
|
||||||
+61
-3
@@ -1,6 +1,8 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { isSocialNetworkId } from "./integrations/social";
|
||||||
|
import type { SocialLink } from "./integrations/types";
|
||||||
|
|
||||||
export type Post = {
|
export type Post = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,6 +12,8 @@ export type Post = {
|
|||||||
body: string;
|
body: string;
|
||||||
author: string;
|
author: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
/** Links to where this post was shared on social networks. */
|
||||||
|
links: SocialLink[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,9 +25,37 @@ type Row = {
|
|||||||
body: string;
|
body: string;
|
||||||
author: string;
|
author: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
|
links: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Parse the per-post links JSON, discarding anything malformed so a bad row can
|
||||||
|
// never break rendering.
|
||||||
|
function parseLinks(raw: string | null): SocialLink[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr
|
||||||
|
.map((o): SocialLink | null => {
|
||||||
|
const network = (o as Record<string, unknown>)?.network;
|
||||||
|
const url = (o as Record<string, unknown>)?.url;
|
||||||
|
if (!isSocialNetworkId(network) || typeof url !== "string" || !url.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = (o as Record<string, unknown>)?.label;
|
||||||
|
return {
|
||||||
|
network,
|
||||||
|
url: url.trim(),
|
||||||
|
label: typeof label === "string" && label.trim() ? label.trim() : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((l): l is SocialLink => l !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toPost(r: Row): Post {
|
function toPost(r: Row): Post {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -33,6 +65,7 @@ function toPost(r: Row): Post {
|
|||||||
body: r.body,
|
body: r.body,
|
||||||
author: r.author,
|
author: r.author,
|
||||||
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||||
|
links: parseLinks(r.links),
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -65,9 +98,32 @@ export type PostInput = {
|
|||||||
body?: string;
|
body?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
tags?: string; // comma-separated
|
tags?: string; // comma-separated
|
||||||
|
/** Social links, one per line as `network|url` or `network|url|label`. */
|
||||||
|
links?: string;
|
||||||
createdAt?: 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
|
// 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).
|
// given, that row is allowed to keep its own slug (used during edits).
|
||||||
export function slugify(input: string): string {
|
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 slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
|
||||||
const info = getDb()
|
const info = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO posts (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, @created_at)`
|
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @links, @created_at)`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
slug,
|
slug,
|
||||||
@@ -106,6 +162,7 @@ export function createPost(input: PostInput): Post {
|
|||||||
body: input.body ?? "",
|
body: input.body ?? "",
|
||||||
author: input.author?.trim() || "webmaster",
|
author: input.author?.trim() || "webmaster",
|
||||||
tags: normalizeTags(input.tags),
|
tags: normalizeTags(input.tags),
|
||||||
|
links: serializeLinks(input.links),
|
||||||
created_at: input.createdAt?.trim() || nowStamp(),
|
created_at: input.createdAt?.trim() || nowStamp(),
|
||||||
});
|
});
|
||||||
return getPostById(Number(info.lastInsertRowid))!;
|
return getPostById(Number(info.lastInsertRowid))!;
|
||||||
@@ -123,7 +180,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
|
|||||||
`UPDATE posts
|
`UPDATE posts
|
||||||
SET slug = @slug, title = @title, excerpt = @excerpt,
|
SET slug = @slug, title = @title, excerpt = @excerpt,
|
||||||
body = @body, author = @author, tags = @tags,
|
body = @body, author = @author, tags = @tags,
|
||||||
created_at = @created_at
|
links = @links, created_at = @created_at
|
||||||
WHERE id = @id`
|
WHERE id = @id`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
@@ -134,6 +191,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
|
|||||||
body: input.body ?? "",
|
body: input.body ?? "",
|
||||||
author: input.author?.trim() || "webmaster",
|
author: input.author?.trim() || "webmaster",
|
||||||
tags: normalizeTags(input.tags),
|
tags: normalizeTags(input.tags),
|
||||||
|
links: serializeLinks(input.links),
|
||||||
created_at: input.createdAt?.trim() || existing.createdAt,
|
created_at: input.createdAt?.trim() || existing.createdAt,
|
||||||
});
|
});
|
||||||
return getPostById(id);
|
return getPostById(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user