Compare commits

...

2 Commits

Author SHA1 Message Date
kawa 1f195a16de 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>
2026-06-07 04:26:51 +02:00
kawa 2f373e683b docs: rebrand README + add Docker and bare-metal deploy
- Rewrite README with RetroBlog branding, config table, and two deploy
  paths (Docker Compose + bare-metal/systemd).
- Enable Next.js standalone output for slim runtime images.
- Add multi-stage Dockerfile (builds better-sqlite3 natively, runs as
  non-root, persists /app/data), docker-compose.yml, and .dockerignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:57:35 +02:00
26 changed files with 2049 additions and 37 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
.git
.gitignore
data
*.tsbuildinfo
next-env.d.ts
.env*
README.md
Dockerfile
.dockerignore
docker-compose.yml
npm-debug.log*
.pnpm-debug.log*
.DS_Store
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
# ---- deps: install node_modules (native build tools for better-sqlite3) ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- builder: compile the Next.js standalone server ----
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ---- runner: minimal runtime image ----
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
# Non-root user; owns the data volume so SQLite can write.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Standalone output bundles the traced node_modules (incl. the better-sqlite3
# native binary), so no install/rebuild is needed here.
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+146 -33
View File
@@ -1,50 +1,163 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). <div align="center">
## Getting Started # 🕹️ RetroBlog
First, run the development server: **A self-hosted blog engine with swappable OS / game-console skins.**
Write Markdown posts, flip between retro themes (Windows, PS1, PS2, PS3, Wii,
NDS, Dreamcast, JV2002…), and run the whole thing from a single SQLite file.
</div>
---
## Features
- **Markdown posts** — create / edit / delete with slugs, tags, excerpts, dates.
- **Swappable skins** — pick the default theme; optionally let visitors switch.
- **Admin panel** — single-password login, signed session cookie.
- **Import / export** — JSON backup of settings and posts.
- **Zero external services** — data lives in one SQLite file (`data/blog.db`).
Stack: Next.js 16 (App Router) · React 19 · better-sqlite3 · marked.
---
## Quick start (development)
```bash ```bash
npm run dev pnpm install
# or
yarn dev
# or
pnpm dev pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open <http://localhost:3000>. Admin panel at <http://localhost:3000/admin>
(default password `admin` in dev).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ---
## Configuration
All config is environment variables. Copy `.env.example` to `.env` and edit.
| Variable | Required | Default | Purpose |
| ---------------------- | -------- | ---------------- | --------------------------------------------------------- |
| `ADMIN_PASSWORD` | **prod** | `admin` | Password for `/admin`. **Set this in production.** |
| `ADMIN_SESSION_SECRET` | **prod** | `ADMIN_PASSWORD` | Signs the admin session cookie. Use a long random string. |
| `PORT` | no | `3000` | Port the server listens on. |
Generate a secret:
```bash
openssl rand -hex 32
```
**Data** is stored in `./data/blog.db` (created automatically). Back up that
directory to back up the whole site.
---
## Deploy — Docker (recommended)
Needs Docker + the Compose plugin.
```bash
# 1. Set production secrets
cp .env.example .env
$EDITOR .env # set ADMIN_PASSWORD and ADMIN_SESSION_SECRET
# 2. Build and run
docker compose up -d --build
```
Site is live on <http://localhost:3000>. The database persists in the
`retroblog-data` Docker volume across rebuilds.
Common operations:
```bash
docker compose logs -f # tail logs
docker compose down # stop (keeps the data volume)
docker compose up -d --build # update after pulling new code
```
**Back up the database:**
```bash
docker compose exec retroblog sh -c 'cat /app/data/blog.db' > backup-$(date +%F).db
```
> Put a TLS-terminating reverse proxy (Caddy, nginx, Traefik) in front for
> HTTPS in production. RetroBlog speaks plain HTTP on `PORT`.
---
## Deploy — bare metal
Needs Node.js 22+ and `pnpm` (`corepack enable`). A C toolchain
(`build-essential`, `python3`) is required once, to compile better-sqlite3.
```bash
# 1. Install deps and build
pnpm install --frozen-lockfile
pnpm build
# 2. Set production secrets
export ADMIN_PASSWORD='your-strong-password'
export ADMIN_SESSION_SECRET="$(openssl rand -hex 32)"
# 3. Start
pnpm start # serves on PORT (default 3000)
```
### Run it as a service (systemd)
`/etc/systemd/system/retroblog.service`:
```ini
[Unit]
Description=RetroBlog
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/retroblog
ExecStart=/usr/bin/pnpm start
Restart=on-failure
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=ADMIN_PASSWORD=your-strong-password
Environment=ADMIN_SESSION_SECRET=your-long-random-secret
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now retroblog
```
The SQLite database lives in `WorkingDirectory/data/blog.db` — make sure the
service `User` can write there, and back up that file.
---
## Admin panel ## Admin panel
Visit [http://localhost:3000/admin](http://localhost:3000/admin) and sign in with Sign in at `/admin` with `ADMIN_PASSWORD`. From there:
`ADMIN_PASSWORD` (see `.env.example`; defaults to `admin` in dev). From there you can:
- **Posts** — full create / edit / delete with Markdown bodies, slugs, tags, and dates. - **Posts** — full create / edit / delete with Markdown bodies, slugs, tags, dates.
- **Settings** — branding (title, subtitle, footer, version), the default theme for - **Settings** — branding (title, subtitle, footer, version), default theme for
new visitors, whether the public theme switcher is shown, and which skins it offers. new visitors, whether the public theme switcher is shown, and which skins it offers.
- **Import / export** — download a JSON backup of settings and/or posts, and import - **Import / export** — download a JSON backup of settings and/or posts, and
one back (posts can replace or append). import one back (posts can replace or append).
Auth is a single password kept in `ADMIN_PASSWORD`, with a signed session cookie All `/admin/*` routes are gated by middleware behind the signed session cookie.
(`ADMIN_SESSION_SECRET`). All `/admin/*` routes are gated by middleware.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ---
## Learn More ## License
To learn more about Next.js, take a look at the following resources: See repository.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
services:
retroblog:
build: .
image: retroblog:latest
container_name: retroblog
restart: unless-stopped
ports:
- "3000:3000"
environment:
# CHANGE THESE before exposing to the internet.
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-change-me-to-a-long-random-string}
volumes:
# Persist the SQLite database across container rebuilds.
- retroblog-data:/app/data
volumes:
retroblog-data:
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ // Emit a self-contained server in .next/standalone for slim Docker images
// and simple bare-metal deploys.
output: "standalone",
}; };
export default nextConfig; export default nextConfig;
+18
View File
@@ -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>
); );
+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"> <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
+19
View File
@@ -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"}
+80
View File
@@ -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");
}
+20
View File
@@ -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;
}
+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 { :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; }
}
+7
View File
@@ -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>
); );
+19
View File
@@ -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>
+36
View File
@@ -0,0 +1,36 @@
import { socialNetwork } from "@/lib/integrations/social";
import type { SocialLink } from "@/lib/integrations/types";
// Theme-agnostic row of social links. Reused for the curated profile links and
// for per-post "shared on" links. Styling lives in the `rb-social*` classes.
export default function SocialLinks({
links,
className = "",
}: {
links: SocialLink[];
className?: string;
}) {
if (links.length === 0) return null;
return (
<ul className={`rb-social ${className}`.trim()}>
{links.map((l, i) => {
const net = socialNetwork(l.network);
return (
<li key={`${l.network}-${i}`}>
<a
className="rb-social-link"
href={l.url}
target="_blank"
rel="me noreferrer"
>
<span className="rb-social-icon" aria-hidden>
{net.icon}
</span>
<span className="rb-social-name">{l.label ?? net.name}</span>
</a>
</li>
);
})}
</ul>
);
}
+25
View File
@@ -41,7 +41,32 @@ function migrate(db: Database.Database) {
id INTEGER PRIMARY KEY CHECK (id = 1), 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) {
+89
View File
@@ -0,0 +1,89 @@
import "server-only";
import { getDb } from "../db";
// Lazy TTL cache for external platform fetches. Each platform stores one row.
// On a fresh hit we return the cached payload; when stale we run the fetcher,
// persist the result, and — crucially — fall back to the last good payload if
// the fetcher fails, so a transient API outage never blanks the bio page.
type CacheRow = {
data: string;
error: string | null;
fetched_at: string;
};
function read(key: string): CacheRow | undefined {
return getDb()
.prepare("SELECT data, error, fetched_at FROM integration_cache WHERE key = ?")
.get(key) as CacheRow | undefined;
}
function write(key: string, data: string, error: string | null): void {
getDb()
.prepare(
`INSERT INTO integration_cache (key, data, error, fetched_at)
VALUES (@key, @data, @error, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = @data, error = @error,
fetched_at = datetime('now')`
)
.run({ key, data, error });
}
function ageMinutes(iso: string): number {
const t = new Date(iso.replace(" ", "T") + "Z").getTime();
if (Number.isNaN(t)) return Infinity;
return (Date.now() - t) / 60000;
}
export type Cached<T> = { data: T; fetchedAt: string; error?: string };
/**
* Return cached `key` data if younger than `ttlMinutes`, otherwise refetch.
* The fetcher itself should never throw (catch internally) but we guard anyway.
*/
export async function cached<T>(
key: string,
ttlMinutes: number,
fallback: T,
fetcher: () => Promise<T>
): Promise<Cached<T>> {
const row = read(key);
if (row && ageMinutes(row.fetched_at) < ttlMinutes) {
try {
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: row.error ?? undefined };
} catch {
/* fall through to refetch on corrupt cache */
}
}
try {
const fresh = await fetcher();
write(key, JSON.stringify(fresh), null);
return { data: fresh, fetchedAt: new Date().toISOString() };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Keep the previous good payload visible; just surface the error.
if (row) {
try {
write(key, row.data, msg);
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: msg };
} catch {
/* corrupt previous payload — fall through */
}
}
write(key, JSON.stringify(fallback), msg);
return { data: fallback, fetchedAt: new Date().toISOString(), error: msg };
}
}
/** Force the next read of these keys to refetch by deleting their rows. */
export function clearCache(keys?: string[]): void {
const db = getDb();
if (!keys || keys.length === 0) {
db.prepare("DELETE FROM integration_cache").run();
return;
}
const stmt = db.prepare("DELETE FROM integration_cache WHERE key = ?");
const tx = db.transaction((ks: string[]) => ks.forEach((k) => stmt.run(k)));
tx(keys);
}
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import { getDb } from "../db";
import { isSocialNetworkId, type SocialNetworkId } from "./social";
import type {
ConsoleItem,
FavoriteGame,
IntegrationConfig,
SocialLink,
} from "./types";
// Integrations config persisted as a single JSON row, mirroring the settings
// module. Holds platform credentials (secrets) alongside curated profile
// content, so it is deliberately NOT part of the export/import surface.
export const DEFAULT_CONFIG: IntegrationConfig = {
displayName: "",
bio: "",
avatarUrl: "",
social: [],
consoles: [],
favorites: [],
steam: { enabled: false, apiKey: "", steamId: "" },
psn: { enabled: false, npsso: "" },
xbox: { enabled: false, apiKey: "", xuid: "" },
cacheTtlMinutes: 360,
};
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function bool(v: unknown, fallback = false): boolean {
return typeof v === "boolean" ? v : fallback;
}
function normSocial(input: unknown): SocialLink[] {
if (!Array.isArray(input)) return [];
const out: SocialLink[] = [];
for (const raw of input) {
const o = (raw ?? {}) as Record<string, unknown>;
const network = o.network;
const url = str(o.url).trim();
if (!isSocialNetworkId(network) || !url) continue;
out.push({
network: network as SocialNetworkId,
url,
label: str(o.label).trim() || undefined,
});
}
return out;
}
function normConsoles(input: unknown): ConsoleItem[] {
if (!Array.isArray(input)) return [];
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
})
.filter((c) => c.name);
}
function normFavorites(input: unknown): FavoriteGame[] {
if (!Array.isArray(input)) return [];
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
})
.filter((f) => f.name);
}
export function normalizeConfig(input: unknown): IntegrationConfig {
const o = (input ?? {}) as Record<string, unknown>;
const steam = (o.steam ?? {}) as Record<string, unknown>;
const psn = (o.psn ?? {}) as Record<string, unknown>;
const xbox = (o.xbox ?? {}) as Record<string, unknown>;
const ttl = Number(o.cacheTtlMinutes);
return {
displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
bio: str(o.bio, DEFAULT_CONFIG.bio),
avatarUrl: str(o.avatarUrl, DEFAULT_CONFIG.avatarUrl).trim(),
social: normSocial(o.social),
consoles: normConsoles(o.consoles),
favorites: normFavorites(o.favorites),
steam: {
enabled: bool(steam.enabled),
apiKey: str(steam.apiKey).trim(),
steamId: str(steam.steamId).trim(),
},
psn: {
enabled: bool(psn.enabled),
npsso: str(psn.npsso).trim(),
},
xbox: {
enabled: bool(xbox.enabled),
apiKey: str(xbox.apiKey).trim(),
xuid: str(xbox.xuid).trim(),
},
cacheTtlMinutes:
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
};
}
export function getIntegrationConfig(): IntegrationConfig {
const row = getDb()
.prepare("SELECT data FROM integrations WHERE id = 1")
.get() as { data: string } | undefined;
if (!row) return DEFAULT_CONFIG;
try {
return normalizeConfig(JSON.parse(row.data));
} catch {
return DEFAULT_CONFIG;
}
}
export function saveIntegrationConfig(next: IntegrationConfig): IntegrationConfig {
const clean = normalizeConfig(next);
getDb()
.prepare(
`INSERT INTO integrations (id, data) VALUES (1, @data)
ON CONFLICT(id) DO UPDATE SET data = @data`
)
.run({ data: JSON.stringify(clean) });
return clean;
}
+127
View File
@@ -0,0 +1,127 @@
import "server-only";
import { marked } from "marked";
import { cached, clearCache, type Cached } from "./cache";
import { getIntegrationConfig } from "./config";
import { fetchSteam } from "./steam";
import { fetchPsn } from "./psn";
import { fetchXbox } from "./xbox";
import type {
Game,
IntegrationConfig,
PlatformData,
PlatformId,
Profile,
} from "./types";
export { getIntegrationConfig, saveIntegrationConfig, DEFAULT_CONFIG } from "./config";
export type { IntegrationConfig } from "./types";
const CACHE_KEYS: Record<PlatformId, string> = {
steam: "platform:steam",
psn: "platform:psn",
xbox: "platform:xbox",
};
function emptyPlatform(platform: PlatformId): PlatformData {
return { platform, games: [], achievements: [], fetchedAt: new Date().toISOString() };
}
/** Fetch one platform through the TTL cache. */
async function loadPlatform(
platform: PlatformId,
cfg: IntegrationConfig
): Promise<PlatformData> {
const ttl = cfg.cacheTtlMinutes;
const key = CACHE_KEYS[platform];
const fallback = emptyPlatform(platform);
let result: Cached<PlatformData>;
switch (platform) {
case "steam":
result = await cached(key, ttl, fallback, () => fetchSteam(cfg.steam));
break;
case "psn":
result = await cached(key, ttl, fallback, () => fetchPsn(cfg.psn));
break;
case "xbox":
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
break;
}
// Surface a cache-layer error onto the payload if the fetch's own error is unset.
return result.error && !result.data.error
? { ...result.data, error: result.error, fetchedAt: result.fetchedAt }
: { ...result.data, fetchedAt: result.fetchedAt };
}
function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
const out: PlatformId[] = [];
if (cfg.steam.enabled) out.push("steam");
if (cfg.psn.enabled) out.push("psn");
if (cfg.xbox.enabled) out.push("xbox");
return out;
}
function gameTime(g: Game): number {
return g.lastPlayed ? new Date(g.lastPlayed).getTime() : 0;
}
/** Assemble the full public profile: curated content + cached platform data. */
export async function getProfile(): Promise<Profile> {
const cfg = getIntegrationConfig();
const platforms = await Promise.all(
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
);
const games = platforms
.flatMap((p) => p.games)
.sort((a, b) => gameTime(b) - gameTime(a));
const achievements = platforms
.flatMap((p) => p.achievements)
.sort(
(a, b) =>
new Date(b.unlockedAt ?? 0).getTime() - new Date(a.unlockedAt ?? 0).getTime()
);
return {
displayName: cfg.displayName,
bioHtml: cfg.bio ? (marked.parse(cfg.bio, { async: false }) as string) : "",
avatarUrl: cfg.avatarUrl,
social: cfg.social,
consoles: cfg.consoles,
favorites: cfg.favorites,
games,
achievements,
platforms,
};
}
/** Latest game across all enabled platforms — for the persistent shell widget. */
export async function getNowPlaying(): Promise<Game | null> {
const cfg = getIntegrationConfig();
if (enabledPlatforms(cfg).length === 0) return null;
const platforms = await Promise.all(
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
);
const games = platforms.flatMap((p) => p.games);
if (games.length === 0) return null;
// Prefer most-recently-played; platforms without timestamps fall to list order.
const timed = games.filter((g) => g.lastPlayed);
if (timed.length) return timed.sort((a, b) => gameTime(b) - gameTime(a))[0];
return games[0];
}
/** True if any platform integration is switched on (gates the public bio link). */
export function hasIntegrations(): boolean {
const cfg = getIntegrationConfig();
return (
enabledPlatforms(cfg).length > 0 ||
cfg.social.length > 0 ||
cfg.consoles.length > 0 ||
cfg.favorites.length > 0 ||
cfg.bio.trim().length > 0
);
}
/** Drop cached platform payloads so the next read refetches. */
export function refreshPlatforms(): void {
clearCache(Object.values(CACHE_KEYS));
}
+124
View File
@@ -0,0 +1,124 @@
import "server-only";
import type { Achievement, Game, PlatformData, PsnConfig } from "./types";
// PlayStation Network has no official public API. We replicate the well-known
// NPSSO -> authorization-code -> access-token exchange (the same flow the
// `psn-api` library performs) using raw fetch, then read the user's trophy
// titles. This is ToS-gray and brittle by nature; every step degrades to a
// thrown Error which the cache layer turns into a surfaced status, never a crash.
const AUTH_BASE = "https://ca.account.sony.com/api/authz/v3/oauth";
const API_BASE = "https://m.np.playstation.com/api";
const CLIENT_ID = "09515159-7237-4370-9b40-3806e67c0891";
const REDIRECT = "com.scee.psxandroid.scecompcall://redirect";
// Public basic-auth pair used by the PSN mobile client (client_id:client_secret).
const BASIC = "MDk1MTUxNTktNzIzNy00MzcwLTliNDAtMzgwNmU2N2MwODkxOnVjUGprYTV0bnRCMktxc1A=";
const TIMEOUT = 8000;
/** NPSSO cookie -> short-lived authorization code. */
async function exchangeCode(npsso: string): Promise<string> {
const params = new URLSearchParams({
access_type: "offline",
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
response_type: "code",
scope: "psn:mobile.v2.core psn:clientapp",
});
const res = await fetch(`${AUTH_BASE}/authorize?${params}`, {
method: "GET",
redirect: "manual",
headers: { Cookie: `npsso=${npsso}` },
signal: AbortSignal.timeout(TIMEOUT),
});
const location = res.headers.get("location") ?? "";
const code = new URL(location, "https://ca.account.sony.com").searchParams.get("code");
if (!code) throw new Error("PSN: could not obtain auth code (NPSSO expired or invalid).");
return code;
}
/** Authorization code -> access token. */
async function exchangeToken(code: string): Promise<string> {
const res = await fetch(`${AUTH_BASE}/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BASIC}`,
},
body: new URLSearchParams({
code,
redirect_uri: REDIRECT,
grant_type: "authorization_code",
token_format: "jwt",
}),
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`PSN token exchange failed (${res.status}).`);
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("PSN: no access token returned.");
return json.access_token;
}
type TrophyTitle = {
npCommunicationId: string;
trophyTitleName: string;
trophyTitleIconUrl?: string;
trophyTitlePlatform?: string;
lastUpdatedDateTime?: string;
earnedTrophies?: { bronze: number; silver: number; gold: number; platinum: number };
};
async function trophyTitles(token: string): Promise<TrophyTitle[]> {
const res = await fetch(
`${API_BASE}/trophy/v1/users/me/trophyTitles?limit=16`,
{
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT),
}
);
if (!res.ok) throw new Error(`PSN trophyTitles failed (${res.status}).`);
const json = (await res.json()) as { trophyTitles?: TrophyTitle[] };
return json.trophyTitles ?? [];
}
export async function fetchPsn(cfg: PsnConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "psn",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.npsso) return { ...base, error: "PSN NPSSO token is required." };
const token = await exchangeToken(await exchangeCode(cfg.npsso));
const titles = await trophyTitles(token);
const sorted = [...titles].sort(
(a, b) =>
new Date(b.lastUpdatedDateTime ?? 0).getTime() -
new Date(a.lastUpdatedDateTime ?? 0).getTime()
);
const games: Game[] = sorted.map((t) => ({
platform: "psn",
name: t.trophyTitleName,
image: t.trophyTitleIconUrl,
lastPlayed: t.lastUpdatedDateTime,
}));
// Surface platinum trophies as headline achievements (the API does not return
// individual trophy unlocks without a per-title call; platinums are the prize).
const achievements: Achievement[] = sorted
.filter((t) => (t.earnedTrophies?.platinum ?? 0) > 0)
.slice(0, 8)
.map((t) => ({
platform: "psn" as const,
game: t.trophyTitleName,
name: "Platinum Trophy",
icon: t.trophyTitleIconUrl,
unlockedAt: t.lastUpdatedDateTime,
rarity: "platinum",
}));
return { ...base, games, achievements };
}
+57
View File
@@ -0,0 +1,57 @@
// Registry of known social networks. Used to render icons + labels for curated
// profile links and per-post share links. Icons are emoji to avoid shipping any
// image assets — they reskin fine across every theme.
export type SocialNetworkId =
| "x"
| "mastodon"
| "bluesky"
| "github"
| "youtube"
| "twitch"
| "instagram"
| "tiktok"
| "linkedin"
| "discord"
| "reddit"
| "steam"
| "psn"
| "xbox"
| "rss"
| "website";
export type SocialNetwork = {
id: SocialNetworkId;
name: string;
/** Emoji glyph used as a lightweight, theme-agnostic icon. */
icon: string;
};
export const SOCIAL_NETWORKS: SocialNetwork[] = [
{ id: "x", name: "X / Twitter", icon: "𝕏" },
{ id: "mastodon", name: "Mastodon", icon: "🐘" },
{ id: "bluesky", name: "Bluesky", icon: "🦋" },
{ id: "github", name: "GitHub", icon: "🐙" },
{ id: "youtube", name: "YouTube", icon: "▶️" },
{ id: "twitch", name: "Twitch", icon: "🎮" },
{ id: "instagram", name: "Instagram", icon: "📷" },
{ id: "tiktok", name: "TikTok", icon: "🎵" },
{ id: "linkedin", name: "LinkedIn", icon: "💼" },
{ id: "discord", name: "Discord", icon: "💬" },
{ id: "reddit", name: "Reddit", icon: "👽" },
{ id: "steam", name: "Steam", icon: "🕹️" },
{ id: "psn", name: "PlayStation", icon: "🎯" },
{ id: "xbox", name: "Xbox", icon: "🟢" },
{ id: "rss", name: "RSS", icon: "📡" },
{ id: "website", name: "Website", icon: "🌐" },
];
const BY_ID = new Map(SOCIAL_NETWORKS.map((n) => [n.id, n]));
export function isSocialNetworkId(v: unknown): v is SocialNetworkId {
return typeof v === "string" && BY_ID.has(v as SocialNetworkId);
}
export function socialNetwork(id: SocialNetworkId): SocialNetwork {
return BY_ID.get(id) ?? { id: "website", name: "Website", icon: "🌐" };
}
+120
View File
@@ -0,0 +1,120 @@
import "server-only";
import type { Achievement, Game, PlatformData, SteamConfig } from "./types";
// Steam Web API. The only platform here with a sane, official, key-based API.
// Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API
//
// We pull the recently-played games, then fetch unlocked achievements (with
// human names from the game schema) for the single most-recent title to keep
// the request count bounded.
const API = "https://api.steampowered.com";
const TIMEOUT = 8000;
function header(appid: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
}
async function getJson(url: string): Promise<unknown> {
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) throw new Error(`Steam API ${res.status} ${res.statusText}`);
return res.json();
}
type RecentGame = {
appid: number;
name: string;
playtime_forever?: number;
playtime_2weeks?: number;
};
async function recentGames(key: string, steamId: string): Promise<RecentGame[]> {
const url = `${API}/IPlayerService/GetRecentlyPlayedGames/v1/?key=${key}&steamid=${steamId}&count=12&format=json`;
const json = (await getJson(url)) as { response?: { games?: RecentGame[] } };
return json.response?.games ?? [];
}
type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string };
async function achievementsFor(
key: string,
steamId: string,
appid: number,
gameName: string
): Promise<Achievement[]> {
// Player progress.
const progUrl = `${API}/ISteamUserStats/GetPlayerAchievements/v1/?key=${key}&steamid=${steamId}&appid=${appid}&l=en`;
let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
try {
const json = (await getJson(progUrl)) as {
playerstats?: { achievements?: typeof progress };
};
progress = json.playerstats?.achievements ?? [];
} catch {
return []; // many games expose no achievements; treat as none
}
// Names + icons come from the game schema.
const schemaUrl = `${API}/ISteamUserStats/GetSchemaForGame/v2/?key=${key}&appid=${appid}&l=en`;
const schema = new Map<string, SchemaAch>();
try {
const json = (await getJson(schemaUrl)) as {
game?: { availableGameStats?: { achievements?: SchemaAch[] } };
};
for (const a of json.game?.availableGameStats?.achievements ?? []) {
schema.set(a.name, a);
}
} catch {
/* names just stay as api ids */
}
return progress
.filter((a) => a.achieved === 1)
.sort((a, b) => b.unlocktime - a.unlocktime)
.slice(0, 8)
.map((a) => {
const meta = schema.get(a.apiname);
return {
platform: "steam" as const,
game: gameName,
name: meta?.displayName ?? a.apiname,
description: meta?.description,
icon: meta?.icon,
unlockedAt: a.unlocktime ? new Date(a.unlocktime * 1000).toISOString() : undefined,
};
});
}
export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "steam",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.apiKey || !cfg.steamId) {
return { ...base, error: "Steam API key and SteamID are required." };
}
const recent = await recentGames(cfg.apiKey, cfg.steamId);
const games: Game[] = recent.map((g) => ({
platform: "steam",
name: g.name,
image: header(g.appid),
url: `https://store.steampowered.com/app/${g.appid}`,
playtimeMinutes: g.playtime_forever,
}));
let achievements: Achievement[] = [];
if (recent[0]) {
achievements = await achievementsFor(
cfg.apiKey,
cfg.steamId,
recent[0].appid,
recent[0].name
);
}
return { ...base, games, achievements };
}
+118
View File
@@ -0,0 +1,118 @@
// Shared types for the integrations system. No server-only imports here so the
// shapes can be referenced from client components and serialized freely.
import type { SocialNetworkId } from "./social";
/** A curated link to a profile on an external network. */
export type SocialLink = {
network: SocialNetworkId;
url: string;
/** Optional override label; falls back to the network's display name. */
label?: string;
};
/** The platforms we can auto-fetch gameplay data from. */
export type PlatformId = "steam" | "psn" | "xbox";
/** A game surfaced from a platform's "recently played" / activity feed. */
export type Game = {
platform: PlatformId;
name: string;
/** Cover / header / icon image URL, if the platform gives us one. */
image?: string;
/** Link to the game/store/profile page. */
url?: string;
/** ISO timestamp of the last play session, if known. */
lastPlayed?: string;
/** Total playtime in minutes, if known. */
playtimeMinutes?: number;
};
/** A single unlocked (or notable) achievement / trophy. */
export type Achievement = {
platform: PlatformId;
/** Game the achievement belongs to. */
game: string;
name: string;
description?: string;
icon?: string;
/** ISO timestamp of unlock, if known. */
unlockedAt?: string;
/** PSN trophy type or rarity flavor, if known. */
rarity?: string;
};
/** A console the author owns / has owned (curated by hand). */
export type ConsoleItem = {
name: string;
note?: string;
};
/** An all-time favorite game (curated by hand). */
export type FavoriteGame = {
name: string;
note?: string;
};
/** Per-platform credential + toggle config. */
export type SteamConfig = {
enabled: boolean;
apiKey: string;
/** 64-bit SteamID. */
steamId: string;
};
export type PsnConfig = {
enabled: boolean;
/** NPSSO token extracted from a logged-in PSN web session. */
npsso: string;
};
export type XboxConfig = {
enabled: boolean;
/** OpenXBL (xbl.io) API key. */
apiKey: string;
/** Optional XUID; OpenXBL infers the authed account if blank. */
xuid: string;
};
/** Everything the admin curates + the platform credentials. Holds secrets. */
export type IntegrationConfig = {
displayName: string;
/** Markdown bio shown at the top of the profile. */
bio: string;
avatarUrl: string;
social: SocialLink[];
consoles: ConsoleItem[];
favorites: FavoriteGame[];
steam: SteamConfig;
psn: PsnConfig;
xbox: XboxConfig;
/** Cache freshness window for platform fetches, in minutes. */
cacheTtlMinutes: number;
};
/** Result of fetching one platform — always returned, never throws. */
export type PlatformData = {
platform: PlatformId;
games: Game[];
achievements: Achievement[];
/** Human-readable error if the last fetch failed (data may be stale/empty). */
error?: string;
/** ISO timestamp of when this payload was fetched. */
fetchedAt: string;
};
/** The fully-assembled profile rendered on the public bio page. */
export type Profile = {
displayName: string;
bioHtml: string;
avatarUrl: string;
social: SocialLink[];
consoles: ConsoleItem[];
favorites: FavoriteGame[];
games: Game[];
achievements: Achievement[];
/** Per-platform fetch status (for showing errors / freshness in admin). */
platforms: PlatformData[];
};
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import type { Achievement, Game, PlatformData, XboxConfig } from "./types";
// Xbox Live has no official public consumer API. We use OpenXBL (xbl.io), a
// popular third-party proxy: the user signs in there and pastes their API key.
// Docs: https://xbl.io/console
//
// Endpoints used:
// GET /api/v2/player/titleHistory -> recently played titles
// GET /api/v2/achievements -> recent achievement unlocks for the account
const API = "https://xbl.io/api/v2";
const TIMEOUT = 8000;
function headers(apiKey: string): HeadersInit {
return { "X-Authorization": apiKey, Accept: "application/json" };
}
async function getJson(path: string, apiKey: string): Promise<unknown> {
const res = await fetch(`${API}${path}`, {
headers: headers(apiKey),
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`OpenXBL ${res.status} ${res.statusText}`);
return res.json();
}
type TitleEntry = {
name?: string;
displayImage?: string;
titleHistory?: { lastTimePlayed?: string };
achievement?: { currentAchievements?: number };
};
type AchievementEntry = {
name?: string;
titleAssociations?: { name?: string }[];
description?: string;
unlockedDescription?: string;
progressState?: string;
progression?: { timeUnlocked?: string };
mediaAssets?: { name?: string; type?: string; url?: string }[];
rarity?: { currentPercentage?: number };
};
export async function fetchXbox(cfg: XboxConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "xbox",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.apiKey) return { ...base, error: "Xbox (OpenXBL) API key is required." };
// Recently played titles.
const history = (await getJson("/player/titleHistory", cfg.apiKey)) as {
titles?: TitleEntry[];
};
const games: Game[] = (history.titles ?? []).slice(0, 12).map((t) => ({
platform: "xbox",
name: t.name ?? "Unknown title",
image: t.displayImage,
lastPlayed: t.titleHistory?.lastTimePlayed,
}));
// Recent achievement unlocks.
let achievements: Achievement[] = [];
try {
const ach = (await getJson("/achievements", cfg.apiKey)) as {
achievements?: AchievementEntry[];
};
achievements = (ach.achievements ?? [])
.filter((a) => a.progressState === "Achieved")
.slice(0, 8)
.map((a) => ({
platform: "xbox" as const,
game: a.titleAssociations?.[0]?.name ?? "Xbox",
name: a.name ?? "Achievement",
description: a.description ?? a.unlockedDescription,
icon: a.mediaAssets?.find((m) => m.type === "Icon")?.url,
unlockedAt: a.progression?.timeUnlocked,
rarity:
typeof a.rarity?.currentPercentage === "number"
? `${a.rarity.currentPercentage}%`
: undefined,
}));
} catch {
/* achievements are optional; keep the games */
}
return { ...base, games, achievements };
}
+61 -3
View File
@@ -1,6 +1,8 @@
import "server-only"; import "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);