Added easy drop-down to add consoles and games to bio
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
searchConsoles,
|
||||
isIconUrl,
|
||||
type ConsoleDef,
|
||||
} from "@/lib/integrations/consoles";
|
||||
import type { ConsoleItem } from "@/lib/integrations/types";
|
||||
|
||||
// Searchable console picker. Filters the bundled registry locally (instant, no
|
||||
// network), shows clickable suggestions, and keeps the chosen list as chips with
|
||||
// an optional per-console note. The whole selection serializes to a single
|
||||
// hidden input (JSON) so the surrounding server-action form picks it up as
|
||||
// `name` with zero extra wiring.
|
||||
|
||||
function Glyph({ icon }: { icon?: string }) {
|
||||
if (!icon) return <span className="rb-pick-glyph" aria-hidden>🎮</span>;
|
||||
if (isIconUrl(icon))
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img className="rb-pick-glyph-img" src={icon} alt="" />;
|
||||
return (
|
||||
<span className="rb-pick-glyph" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsolePicker({
|
||||
name,
|
||||
initial,
|
||||
}: {
|
||||
name: string;
|
||||
initial: ConsoleItem[];
|
||||
}) {
|
||||
const [items, setItems] = useState<ConsoleItem[]>(initial);
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const chosenIds = useMemo(
|
||||
() => new Set(items.map((i) => i.id).filter(Boolean) as string[]),
|
||||
[items]
|
||||
);
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => searchConsoles(query).filter((c) => !chosenIds.has(c.id)),
|
||||
[query, chosenIds]
|
||||
);
|
||||
|
||||
function add(def: ConsoleDef) {
|
||||
setItems((prev) => [...prev, { id: def.id, name: def.name, icon: def.icon }]);
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function addFreeform() {
|
||||
const label = query.trim();
|
||||
if (!label) return;
|
||||
setItems((prev) => [...prev, { name: label }]);
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function setNote(idx: number, note: string) {
|
||||
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, note } : it)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb-picker">
|
||||
<input type="hidden" name={name} value={JSON.stringify(items)} />
|
||||
|
||||
<div className="rb-pick-search">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder="Search consoles — e.g. Dreamcast, Sega, PS2…"
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (suggestions[0]) add(suggestions[0]);
|
||||
else addFreeform();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{open && (suggestions.length > 0 || query.trim()) && (
|
||||
<ul className="rb-pick-suggest">
|
||||
{suggestions.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(c)}>
|
||||
<Glyph icon={c.icon} />
|
||||
<span className="rb-pick-suggest-name">{c.name}</span>
|
||||
<span className="rb-pick-suggest-meta">
|
||||
{c.maker}
|
||||
{c.year ? ` · ${c.year}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{query.trim() && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="rb-pick-freeform"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={addFreeform}
|
||||
>
|
||||
+ Add “{query.trim()}” as custom
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="rb-pick-list">
|
||||
{items.map((it, i) => (
|
||||
<li key={`${it.id ?? it.name}-${i}`} className="rb-pick-chip">
|
||||
<Glyph icon={it.icon} />
|
||||
<span className="rb-pick-chip-name">{it.name}</span>
|
||||
<input
|
||||
className="rb-pick-chip-note"
|
||||
type="text"
|
||||
value={it.note ?? ""}
|
||||
placeholder="note (optional)"
|
||||
onChange={(e) => setNote(i, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rb-pick-remove"
|
||||
aria-label={`Remove ${it.name}`}
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import type { FavoriteGame } from "@/lib/integrations/types";
|
||||
|
||||
// Searchable favorite-games picker. Mirrors the console picker UX, but results
|
||||
// come from the server (Steam store search via /games/search) since the catalog
|
||||
// is far too large to bundle. Typing debounces a fetch; clicking a result adds
|
||||
// it as a chip with its cover art. Free-text entry is always available for
|
||||
// console/retro titles the search doesn't surface.
|
||||
|
||||
type Result = { name: string; image?: string; url?: string };
|
||||
|
||||
function Cover({ image }: { image?: string }) {
|
||||
if (!image) return <span className="rb-pick-cover rb-pick-cover-empty" aria-hidden>🎲</span>;
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img className="rb-pick-cover" src={image} alt="" loading="lazy" />;
|
||||
}
|
||||
|
||||
export default function GamePicker({
|
||||
name,
|
||||
initial,
|
||||
}: {
|
||||
name: string;
|
||||
initial: FavoriteGame[];
|
||||
}) {
|
||||
const [items, setItems] = useState<FavoriteGame[]>(initial);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Result[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Search is event-driven (no effect): each keystroke reschedules a debounced
|
||||
// fetch against the admin-only Steam search route, cancelling any in-flight
|
||||
// request so only the latest query's results land.
|
||||
function onQueryChange(next: string) {
|
||||
setQuery(next);
|
||||
setOpen(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
const q = next.trim();
|
||||
if (q.length < 2) {
|
||||
abortRef.current?.abort();
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/integrations/games/search?q=${encodeURIComponent(q)}`,
|
||||
{ signal: ctrl.signal }
|
||||
);
|
||||
const json = (await res.json()) as { games?: Result[] };
|
||||
setResults(json.games ?? []);
|
||||
} catch {
|
||||
// aborted or network error — leave prior results, drop the spinner
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function add(g: Result) {
|
||||
setItems((prev) => [...prev, { name: g.name, image: g.image, url: g.url }]);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function addFreeform() {
|
||||
const label = query.trim();
|
||||
if (!label) return;
|
||||
setItems((prev) => [...prev, { name: label }]);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function setNote(idx: number, note: string) {
|
||||
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, note } : it)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb-picker">
|
||||
<input type="hidden" name={name} value={JSON.stringify(items)} />
|
||||
|
||||
<div className="rb-pick-search">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder="Search games — e.g. Chrono Trigger, Hollow Knight…"
|
||||
autoComplete="off"
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (results[0]) add(results[0]);
|
||||
else addFreeform();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{open && query.trim().length >= 2 && (
|
||||
<ul className="rb-pick-suggest">
|
||||
{loading && results.length === 0 && (
|
||||
<li className="rb-pick-status">Searching…</li>
|
||||
)}
|
||||
{results.map((g, i) => (
|
||||
<li key={`${g.url ?? g.name}-${i}`}>
|
||||
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(g)}>
|
||||
<Cover image={g.image} />
|
||||
<span className="rb-pick-suggest-name">{g.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="rb-pick-freeform"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={addFreeform}
|
||||
>
|
||||
+ Add “{query.trim()}” as custom
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="rb-pick-list">
|
||||
{items.map((it, i) => (
|
||||
<li key={`${it.url ?? it.name}-${i}`} className="rb-pick-chip">
|
||||
<Cover image={it.image} />
|
||||
<span className="rb-pick-chip-name">{it.name}</span>
|
||||
<input
|
||||
className="rb-pick-chip-note"
|
||||
type="text"
|
||||
value={it.note ?? ""}
|
||||
placeholder="note (optional)"
|
||||
onChange={(e) => setNote(i, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rb-pick-remove"
|
||||
aria-label={`Remove ${it.name}`}
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
|
||||
// Game search for the admin "favorite games" picker. Backed by Steam's public
|
||||
// store-search endpoint: keyless, returns capsule cover art, and its catalog is
|
||||
// broad enough to cover PC plus the countless console/retro titles that have
|
||||
// since been re-released on Steam. The picker also allows free-text entry for
|
||||
// anything the search can't find, so console-only games are never blocked.
|
||||
//
|
||||
// Admin-gated (it lives under the panel layout, but route handlers don't inherit
|
||||
// that guard, so we check here too) to avoid turning the blog into an open proxy.
|
||||
|
||||
const STORE_SEARCH = "https://store.steampowered.com/api/storesearch/";
|
||||
const TIMEOUT = 6000;
|
||||
|
||||
type StoreItem = { id: number; name: string; tiny_image?: string };
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAdmin())) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const q = (new URL(req.url).searchParams.get("q") ?? "").trim();
|
||||
if (q.length < 2) return NextResponse.json({ games: [] });
|
||||
|
||||
const url = `${STORE_SEARCH}?term=${encodeURIComponent(q)}&cc=us&l=en`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ games: [], error: `Steam ${res.status}` });
|
||||
}
|
||||
const json = (await res.json()) as { items?: StoreItem[] };
|
||||
const games = (json.items ?? []).slice(0, 10).map((it) => ({
|
||||
name: it.name,
|
||||
image: it.tiny_image,
|
||||
url: `https://store.steampowered.com/app/${it.id}`,
|
||||
}));
|
||||
return NextResponse.json({ games });
|
||||
} catch {
|
||||
return NextResponse.json({ games: [], error: "Search timed out." });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,46 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getIntegrationConfig, getProfile } from "@/lib/integrations";
|
||||
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
|
||||
import type {
|
||||
ConsoleItem,
|
||||
FavoriteGame,
|
||||
SocialLink,
|
||||
} from "@/lib/integrations/types";
|
||||
import type { SocialLink } from "@/lib/integrations/types";
|
||||
import {
|
||||
saveIntegrationsAction,
|
||||
refreshIntegrationsAction,
|
||||
testIntegrationAction,
|
||||
} from "../../actions";
|
||||
import ConsolePicker from "./ConsolePicker";
|
||||
import GamePicker from "./GamePicker";
|
||||
|
||||
// Expandable "how do I get this?" help, shown under credential fields. Uses a
|
||||
// native <details> so it works with zero JS and stays readable on mobile.
|
||||
function CredHelp({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<details className="rb-cred-help">
|
||||
<summary>ⓘ {title}</summary>
|
||||
<div className="rb-cred-help-body">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
// A "Test connection" submit button. Submits the whole form to the test action
|
||||
// (so it validates the live, unsaved field values) tagged with which platform.
|
||||
function TestButton({ platform }: { platform: string }) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="rb-btn rb-btn-ghost"
|
||||
formAction={testIntegrationAction.bind(null, platform)}
|
||||
formNoValidate
|
||||
>
|
||||
Test connection
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -29,18 +52,51 @@ function fmtAge(iso: string): string {
|
||||
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>;
|
||||
type Banners = {
|
||||
saved?: string;
|
||||
refreshed?: string;
|
||||
steam?: string;
|
||||
steamErr?: string;
|
||||
test?: string;
|
||||
testErr?: string;
|
||||
testGames?: string;
|
||||
testNotice?: string;
|
||||
};
|
||||
|
||||
function Banner(p: Banners) {
|
||||
if (p.saved) return <p className="rb-admin-ok">Integrations saved. Caches cleared.</p>;
|
||||
if (p.refreshed)
|
||||
return <p className="rb-admin-ok">Platform caches cleared — data refetches on next view.</p>;
|
||||
if (p.steam)
|
||||
return (
|
||||
<p className="rb-admin-ok">
|
||||
Steam linked — SteamID <code>{p.steam}</code> saved. Add your API key below to pull
|
||||
game data.
|
||||
</p>
|
||||
);
|
||||
if (p.steamErr) return <p className="rb-admin-error">{p.steamErr}</p>;
|
||||
if (p.test && p.testErr)
|
||||
return (
|
||||
<p className="rb-admin-error">
|
||||
{p.test.toUpperCase()} test failed: {p.testErr}
|
||||
</p>
|
||||
);
|
||||
if (p.test)
|
||||
return (
|
||||
<p className={p.testNotice ? "rb-admin-warn" : "rb-admin-ok"}>
|
||||
{p.test.toUpperCase()} connection OK — fetched {p.testGames ?? 0} game(s).
|
||||
{p.testNotice ? ` ${p.testNotice}` : ""}
|
||||
</p>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ saved?: string; refreshed?: string }>;
|
||||
searchParams: Promise<Banners>;
|
||||
}) {
|
||||
const { saved, refreshed } = await searchParams;
|
||||
const banners = await searchParams;
|
||||
const cfg = getIntegrationConfig();
|
||||
// Loads (cached) platform data so we can show freshness + any fetch errors.
|
||||
const profile = await getProfile();
|
||||
@@ -49,7 +105,7 @@ export default async function IntegrationsPage({
|
||||
return (
|
||||
<div className="rb-admin-page">
|
||||
<h1 className="rb-admin-h1">Integrations</h1>
|
||||
<Banner saved={saved} refreshed={refreshed} />
|
||||
<Banner {...banners} />
|
||||
<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
|
||||
@@ -136,31 +192,19 @@ export default async function IntegrationsPage({
|
||||
|
||||
{/* ---- 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 className="rb-field">
|
||||
<span>
|
||||
Consoles owned{" "}
|
||||
<em className="rb-admin-muted">— search and click to add</em>
|
||||
</span>
|
||||
<ConsolePicker name="consoles" initial={cfg.consoles} />
|
||||
</div>
|
||||
<div className="rb-field">
|
||||
<span>
|
||||
Favorite games{" "}
|
||||
<em className="rb-admin-muted">— search (Steam catalog) and click to add</em>
|
||||
</span>
|
||||
<GamePicker name="favorites" initial={cfg.favorites} />
|
||||
</div>
|
||||
|
||||
{/* ---- platforms ---- */}
|
||||
@@ -169,12 +213,20 @@ export default async function IntegrationsPage({
|
||||
<input type="checkbox" name="steamEnabled" defaultChecked={cfg.steam.enabled} />
|
||||
<span>Enable Steam integration</span>
|
||||
</label>
|
||||
<p className="rb-admin-muted">
|
||||
One-click:{" "}
|
||||
<a className="rb-btn rb-btn-ghost rb-btn-inline" href="/admin/integrations/steam/link">
|
||||
Sign in with Steam
|
||||
</a>{" "}
|
||||
to fill your SteamID automatically{cfg.steam.steamId ? ` (current: ${cfg.steam.steamId})` : ""}.
|
||||
You still add an API key below for game data.
|
||||
</p>
|
||||
<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"})
|
||||
({cfg.steam.apiKey ? "stored — blank keeps it" : "required for game data"})
|
||||
</em>
|
||||
</span>
|
||||
<input
|
||||
@@ -183,12 +235,41 @@ export default async function IntegrationsPage({
|
||||
autoComplete="off"
|
||||
placeholder={cfg.steam.apiKey ? "•••••••• stored" : ""}
|
||||
/>
|
||||
<CredHelp title="How to get a Steam API key">
|
||||
<ol>
|
||||
<li>
|
||||
Go to{" "}
|
||||
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer">
|
||||
steamcommunity.com/dev/apikey
|
||||
</a>{" "}
|
||||
(sign in if asked).
|
||||
</li>
|
||||
<li>Enter any domain name (e.g. <code>localhost</code>) and agree to the terms.</li>
|
||||
<li>Copy the generated key and paste it here.</li>
|
||||
</ol>
|
||||
<p>Your Steam profile must be set to <strong>Public</strong> for game data to load.</p>
|
||||
</CredHelp>
|
||||
</label>
|
||||
<label className="rb-field">
|
||||
<span>SteamID (64-bit)</span>
|
||||
<input name="steamId" defaultValue={cfg.steam.steamId} placeholder="7656119…" />
|
||||
<CredHelp title="How to find your SteamID">
|
||||
<ol>
|
||||
<li>Use the “Sign in with Steam” button above — easiest, fills it for you.</li>
|
||||
<li>
|
||||
Or open{" "}
|
||||
<a href="https://steamid.io" target="_blank" rel="noreferrer">
|
||||
steamid.io
|
||||
</a>
|
||||
, paste your profile URL, and copy the <code>steamID64</code> value.
|
||||
</li>
|
||||
</ol>
|
||||
</CredHelp>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rb-form-actions rb-form-actions-sub">
|
||||
<TestButton platform="steam" />
|
||||
</div>
|
||||
|
||||
<h2 className="rb-admin-h2">PlayStation (PSN)</h2>
|
||||
<label className="rb-check">
|
||||
@@ -199,7 +280,7 @@ export default async function IntegrationsPage({
|
||||
<span>
|
||||
NPSSO token{" "}
|
||||
<em className="rb-admin-muted">
|
||||
({cfg.psn.npsso ? "stored — blank keeps it" : "from ca.account.sony.com/api/v1/ssocookie"})
|
||||
({cfg.psn.npsso ? "stored — blank keeps it" : "required"})
|
||||
</em>
|
||||
</span>
|
||||
<input
|
||||
@@ -208,7 +289,37 @@ export default async function IntegrationsPage({
|
||||
autoComplete="off"
|
||||
placeholder={cfg.psn.npsso ? "•••••••• stored" : "64-char token"}
|
||||
/>
|
||||
<CredHelp title="How to get your NPSSO token">
|
||||
<ol>
|
||||
<li>
|
||||
Sign in at{" "}
|
||||
<a href="https://www.playstation.com" target="_blank" rel="noreferrer">
|
||||
playstation.com
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>
|
||||
In the same browser, open{" "}
|
||||
<a
|
||||
href="https://ca.account.sony.com/api/v1/ssocookie"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
ca.account.sony.com/api/v1/ssocookie
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Copy the 64-character value of <code>npsso</code> from the JSON shown and paste it
|
||||
here.
|
||||
</li>
|
||||
</ol>
|
||||
<p>The token expires periodically — re-paste a fresh one if PSN starts erroring.</p>
|
||||
</CredHelp>
|
||||
</label>
|
||||
<div className="rb-form-actions rb-form-actions-sub">
|
||||
<TestButton platform="psn" />
|
||||
</div>
|
||||
|
||||
<h2 className="rb-admin-h2">Xbox</h2>
|
||||
<label className="rb-check">
|
||||
@@ -220,7 +331,7 @@ export default async function IntegrationsPage({
|
||||
<span>
|
||||
OpenXBL API key{" "}
|
||||
<em className="rb-admin-muted">
|
||||
({cfg.xbox.apiKey ? "stored — blank keeps it" : "from xbl.io"})
|
||||
({cfg.xbox.apiKey ? "stored — blank keeps it" : "required"})
|
||||
</em>
|
||||
</span>
|
||||
<input
|
||||
@@ -229,14 +340,93 @@ export default async function IntegrationsPage({
|
||||
autoComplete="off"
|
||||
placeholder={cfg.xbox.apiKey ? "•••••••• stored" : ""}
|
||||
/>
|
||||
<CredHelp title="How to get an OpenXBL API key">
|
||||
<ol>
|
||||
<li>
|
||||
Go to{" "}
|
||||
<a href="https://xbl.io" target="_blank" rel="noreferrer">
|
||||
xbl.io
|
||||
</a>{" "}
|
||||
and sign in with your Microsoft / Xbox account.
|
||||
</li>
|
||||
<li>Open the console / API keys page and generate a key.</li>
|
||||
<li>Copy the key and paste it here.</li>
|
||||
</ol>
|
||||
<p>Xbox has no official public API; OpenXBL is a third-party proxy.</p>
|
||||
</CredHelp>
|
||||
</label>
|
||||
<label className="rb-field">
|
||||
<span>
|
||||
XUID <em className="rb-admin-muted">(optional)</em>
|
||||
</span>
|
||||
<input name="xboxXuid" defaultValue={cfg.xbox.xuid} />
|
||||
<CredHelp title="Do I need a XUID?">
|
||||
<p>
|
||||
No — leave blank and OpenXBL uses the account tied to your API key. Only set this to
|
||||
read a <em>different</em> profile.
|
||||
</p>
|
||||
</CredHelp>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rb-form-actions rb-form-actions-sub">
|
||||
<TestButton platform="xbox" />
|
||||
</div>
|
||||
|
||||
<h2 className="rb-admin-h2">RetroAchievements</h2>
|
||||
<label className="rb-check">
|
||||
<input type="checkbox" name="retroEnabled" defaultChecked={cfg.retro.enabled} />
|
||||
<span>Enable RetroAchievements integration</span>
|
||||
</label>
|
||||
<div className="rb-field-row">
|
||||
<label className="rb-field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
name="retroUsername"
|
||||
defaultValue={cfg.retro.username}
|
||||
placeholder="your RA username"
|
||||
/>
|
||||
</label>
|
||||
<label className="rb-field">
|
||||
<span>
|
||||
Web API key{" "}
|
||||
<em className="rb-admin-muted">
|
||||
({cfg.retro.apiKey ? "stored — blank keeps it" : "required"})
|
||||
</em>
|
||||
</span>
|
||||
<input
|
||||
name="retroApiKey"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={cfg.retro.apiKey ? "•••••••• stored" : ""}
|
||||
/>
|
||||
<CredHelp title="How to get a RetroAchievements API key">
|
||||
<ol>
|
||||
<li>
|
||||
Sign in at{" "}
|
||||
<a href="https://retroachievements.org" target="_blank" rel="noreferrer">
|
||||
retroachievements.org
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Open{" "}
|
||||
<a
|
||||
href="https://retroachievements.org/settings"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Settings → Keys
|
||||
</a>{" "}
|
||||
and copy your <strong>Web API Key</strong>.
|
||||
</li>
|
||||
<li>Paste it here along with your username above.</li>
|
||||
</ol>
|
||||
</CredHelp>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rb-form-actions rb-form-actions-sub">
|
||||
<TestButton platform="retro" />
|
||||
</div>
|
||||
|
||||
{/* ---- cache ---- */}
|
||||
<h2 className="rb-admin-h2">Caching</h2>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
|
||||
// Step 1 of Steam's one-click sign-in (OpenID 2.0). We bounce the admin to
|
||||
// Steam, which authenticates them and redirects back to /return with their
|
||||
// identity. No API key or secret is needed for this exchange — that's what makes
|
||||
// it one-click: the admin just confirms on Steam and their SteamID flows back.
|
||||
|
||||
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAdmin())) {
|
||||
return NextResponse.redirect(new URL("/admin/login", req.url));
|
||||
}
|
||||
|
||||
const origin = new URL(req.url).origin;
|
||||
const params = new URLSearchParams({
|
||||
"openid.ns": "http://specs.openid.net/auth/2.0",
|
||||
"openid.mode": "checkid_setup",
|
||||
"openid.return_to": `${origin}/admin/integrations/steam/return`,
|
||||
"openid.realm": origin,
|
||||
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${STEAM_OPENID}?${params}`);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
import { getIntegrationConfig, saveIntegrationConfig } from "@/lib/integrations";
|
||||
|
||||
// Step 2 of Steam OpenID sign-in. Steam redirects back here with its assertion.
|
||||
// We MUST verify it by echoing the params back to Steam with mode=check_auth;
|
||||
// trusting the query string blindly would let anyone forge a SteamID. On success
|
||||
// we extract the 64-bit id from the claimed_id URL and persist it. The Steam API
|
||||
// key (needed to actually read game data) is still entered separately.
|
||||
|
||||
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
|
||||
const CLAIMED_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/;
|
||||
|
||||
function back(req: NextRequest, qs: Record<string, string>): NextResponse {
|
||||
const url = new URL("/admin/integrations", req.url);
|
||||
for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, v);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAdmin())) {
|
||||
return NextResponse.redirect(new URL("/admin/login", req.url));
|
||||
}
|
||||
|
||||
const incoming = new URL(req.url).searchParams;
|
||||
const claimed = incoming.get("openid.claimed_id") ?? "";
|
||||
const match = CLAIMED_RE.exec(claimed);
|
||||
if (!match) return back(req, { steamErr: "Steam sign-in returned no identity." });
|
||||
|
||||
// Re-send every openid.* param back to Steam with mode=check_authentication.
|
||||
const verify = new URLSearchParams();
|
||||
for (const [k, v] of incoming) verify.set(k, v);
|
||||
verify.set("openid.mode", "check_authentication");
|
||||
|
||||
let valid = false;
|
||||
try {
|
||||
const res = await fetch(STEAM_OPENID, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: verify,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
valid = (await res.text()).includes("is_valid:true");
|
||||
} catch {
|
||||
return back(req, { steamErr: "Could not reach Steam to verify sign-in." });
|
||||
}
|
||||
if (!valid) return back(req, { steamErr: "Steam sign-in could not be verified." });
|
||||
|
||||
const steamId = match[1];
|
||||
const cfg = getIntegrationConfig();
|
||||
saveIntegrationConfig({ ...cfg, steam: { ...cfg.steam, enabled: true, steamId } });
|
||||
return back(req, { steam: steamId });
|
||||
}
|
||||
+73
-16
@@ -25,12 +25,14 @@ import {
|
||||
getIntegrationConfig,
|
||||
saveIntegrationConfig,
|
||||
refreshPlatforms,
|
||||
testPlatform,
|
||||
} from "@/lib/integrations";
|
||||
import { isSocialNetworkId, type SocialNetworkId } from "@/lib/integrations/social";
|
||||
import type {
|
||||
ConsoleItem,
|
||||
FavoriteGame,
|
||||
IntegrationConfig,
|
||||
PlatformId,
|
||||
SocialLink,
|
||||
} from "@/lib/integrations/types";
|
||||
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
|
||||
@@ -211,31 +213,57 @@ function parseSocialLines(raw: string): SocialLink[] {
|
||||
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 });
|
||||
// The console / game pickers post their selection as a JSON array (one hidden
|
||||
// input each). Parse defensively — a malformed payload yields an empty list
|
||||
// rather than throwing. config.normalize* re-validates field shapes after this.
|
||||
function parseJsonArray(raw: string): Record<string, unknown>[] {
|
||||
if (!raw.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function saveIntegrationsAction(formData: FormData) {
|
||||
const current = getIntegrationConfig();
|
||||
function parseConsolesJson(raw: string): ConsoleItem[] {
|
||||
return parseJsonArray(raw)
|
||||
.map((o) => ({
|
||||
id: typeof o.id === "string" ? o.id : undefined,
|
||||
name: typeof o.name === "string" ? o.name.trim() : "",
|
||||
icon: typeof o.icon === "string" ? o.icon : undefined,
|
||||
note: typeof o.note === "string" ? o.note.trim() || undefined : undefined,
|
||||
}))
|
||||
.filter((c) => c.name);
|
||||
}
|
||||
|
||||
const next: IntegrationConfig = {
|
||||
function parseFavoritesJson(raw: string): FavoriteGame[] {
|
||||
return parseJsonArray(raw)
|
||||
.map((o) => ({
|
||||
name: typeof o.name === "string" ? o.name.trim() : "",
|
||||
image: typeof o.image === "string" ? o.image : undefined,
|
||||
url: typeof o.url === "string" ? o.url : undefined,
|
||||
note: typeof o.note === "string" ? o.note.trim() || undefined : undefined,
|
||||
}))
|
||||
.filter((f) => f.name);
|
||||
}
|
||||
|
||||
// Build a full IntegrationConfig from the admin form. Blank credential fields
|
||||
// fall back to the stored secret so saving never wipes a key the user left
|
||||
// masked. Shared by save + test so both see identical (live) values.
|
||||
function configFromForm(
|
||||
formData: FormData,
|
||||
current: IntegrationConfig
|
||||
): IntegrationConfig {
|
||||
return {
|
||||
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[],
|
||||
consoles: parseConsolesJson(s(formData, "consoles")),
|
||||
favorites: parseFavoritesJson(s(formData, "favorites")),
|
||||
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(),
|
||||
},
|
||||
@@ -248,16 +276,45 @@ export async function saveIntegrationsAction(formData: FormData) {
|
||||
apiKey: s(formData, "xboxApiKey").trim() || current.xbox.apiKey,
|
||||
xuid: s(formData, "xboxXuid").trim(),
|
||||
},
|
||||
retro: {
|
||||
enabled: formData.get("retroEnabled") === "on",
|
||||
username: s(formData, "retroUsername").trim(),
|
||||
apiKey: s(formData, "retroApiKey").trim() || current.retro.apiKey,
|
||||
},
|
||||
cacheTtlMinutes: Number(s(formData, "cacheTtlMinutes")) || current.cacheTtlMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
saveIntegrationConfig(next);
|
||||
export async function saveIntegrationsAction(formData: FormData) {
|
||||
const current = getIntegrationConfig();
|
||||
saveIntegrationConfig(configFromForm(formData, current));
|
||||
// Config changed (creds/toggles) — drop cached payloads so they refetch.
|
||||
refreshPlatforms();
|
||||
revalidateSite();
|
||||
redirect("/admin/integrations?saved=1");
|
||||
}
|
||||
|
||||
const PLATFORM_IDS: PlatformId[] = ["steam", "psn", "xbox", "retro"];
|
||||
function isPlatformId(v: string): v is PlatformId {
|
||||
return (PLATFORM_IDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
// "Test connection": runs the chosen platform's fetcher against the live form
|
||||
// values (not the saved config) so the admin can validate creds before saving.
|
||||
export async function testIntegrationAction(platform: string, formData: FormData) {
|
||||
if (!isPlatformId(platform)) redirect("/admin/integrations");
|
||||
|
||||
const cfg = configFromForm(formData, getIntegrationConfig());
|
||||
const result = await testPlatform(platform, cfg);
|
||||
const qs = new URLSearchParams({ test: platform });
|
||||
if (result.error) qs.set("testErr", result.error);
|
||||
else {
|
||||
qs.set("testGames", String(result.games.length));
|
||||
if (result.notice) qs.set("testNotice", result.notice);
|
||||
}
|
||||
redirect(`/admin/integrations?${qs}`);
|
||||
}
|
||||
|
||||
export async function refreshIntegrationsAction() {
|
||||
refreshPlatforms();
|
||||
revalidateSite();
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
/* ---- banners ---- */
|
||||
|
||||
.rb-admin-ok,
|
||||
.rb-admin-warn,
|
||||
.rb-admin-error {
|
||||
border-radius: var(--a-radius);
|
||||
padding: 10px 14px;
|
||||
@@ -181,6 +182,11 @@
|
||||
color: var(--a-ok-text);
|
||||
}
|
||||
|
||||
.rb-admin-warn {
|
||||
background: color-mix(in srgb, #f5a623 22%, transparent);
|
||||
color: #b8740f;
|
||||
}
|
||||
|
||||
.rb-admin-error {
|
||||
background: var(--a-err-bg);
|
||||
color: var(--a-err-text);
|
||||
@@ -410,3 +416,212 @@
|
||||
color: #555;
|
||||
border-bottom: 2px solid #d0d0d0;
|
||||
}
|
||||
|
||||
/* integrations: per-credential expandable help (tooltips) */
|
||||
.rb-cred-help {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rb-cred-help > summary {
|
||||
cursor: pointer;
|
||||
color: var(--a-muted);
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
.rb-cred-help > summary:hover {
|
||||
color: var(--a-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rb-cred-help[open] > summary {
|
||||
color: var(--a-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.rb-cred-help-body {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid var(--a-border);
|
||||
border-radius: 6px;
|
||||
color: var(--a-text);
|
||||
}
|
||||
.rb-cred-help-body ol {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.rb-cred-help-body li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.rb-cred-help-body p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--a-muted);
|
||||
}
|
||||
|
||||
/* integrations: per-platform action row (e.g. Test connection) */
|
||||
.rb-form-actions-sub {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rb-btn-inline {
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
border-color: var(--a-border);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* integrations: console / game pickers */
|
||||
.rb-picker {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rb-pick-search {
|
||||
position: relative;
|
||||
}
|
||||
.rb-pick-search > input {
|
||||
font: inherit;
|
||||
color: var(--a-text);
|
||||
background: #fff;
|
||||
border: 1px solid var(--a-border);
|
||||
border-radius: 6px;
|
||||
padding: 9px 11px;
|
||||
width: 100%;
|
||||
}
|
||||
.rb-pick-search > input:focus {
|
||||
outline: 2px solid var(--a-primary);
|
||||
outline-offset: -1px;
|
||||
border-color: var(--a-primary);
|
||||
}
|
||||
.rb-pick-suggest {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--a-surface);
|
||||
border: 1px solid var(--a-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
.rb-pick-suggest > li > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--a-text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rb-pick-suggest > li > button:hover {
|
||||
background: #eef1f6;
|
||||
}
|
||||
.rb-pick-status {
|
||||
padding: 8px 9px;
|
||||
color: var(--a-muted);
|
||||
}
|
||||
.rb-pick-suggest-name {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
.rb-pick-suggest-meta {
|
||||
color: var(--a-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-pick-freeform {
|
||||
color: var(--a-primary) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rb-pick-glyph,
|
||||
.rb-pick-glyph-img,
|
||||
.rb-pick-cover {
|
||||
flex: none;
|
||||
}
|
||||
.rb-pick-glyph {
|
||||
font-size: 18px;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.rb-pick-glyph-img {
|
||||
height: 20px;
|
||||
width: auto;
|
||||
max-width: 90px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.rb-pick-cover {
|
||||
width: 46px;
|
||||
height: 22px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--a-border);
|
||||
}
|
||||
.rb-pick-cover-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 22px;
|
||||
background: #eef1f6;
|
||||
border: 1px solid var(--a-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.rb-pick-list {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.rb-pick-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--a-border);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.rb-pick-chip-name {
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
max-width: 38%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-pick-chip-note {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
color: var(--a-text);
|
||||
background: #fff;
|
||||
border: 1px solid var(--a-border);
|
||||
border-radius: 5px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.rb-pick-chip-note:focus {
|
||||
outline: 2px solid var(--a-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.rb-pick-remove {
|
||||
flex: none;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
color: var(--a-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rb-pick-remove:hover {
|
||||
background: var(--a-err-bg);
|
||||
color: var(--a-danger);
|
||||
}
|
||||
|
||||
+42
-7
@@ -3,10 +3,17 @@ import Shell from "@/components/Shell";
|
||||
import SocialLinks from "@/components/SocialLinks";
|
||||
import { getTheme } from "@/themes/server";
|
||||
import { getProfile, hasIntegrations } from "@/lib/integrations";
|
||||
import { isIconUrl } from "@/lib/integrations/consoles";
|
||||
import type { Achievement, Game } from "@/lib/integrations/types";
|
||||
|
||||
function platformLabel(p: string): string {
|
||||
return p === "psn" ? "PlayStation" : p === "xbox" ? "Xbox" : "Steam";
|
||||
const labels: Record<string, string> = {
|
||||
psn: "PlayStation",
|
||||
xbox: "Xbox",
|
||||
retro: "RetroAchievements",
|
||||
steam: "Steam",
|
||||
};
|
||||
return labels[p] ?? "Steam";
|
||||
}
|
||||
|
||||
function fmtPlaytime(mins?: number): string | null {
|
||||
@@ -123,12 +130,31 @@ export default async function BioPage() {
|
||||
<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>
|
||||
))}
|
||||
{profile.favorites.map((f, i) => {
|
||||
const body = (
|
||||
<>
|
||||
{f.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="rb-fav-cover" src={f.image} alt="" loading="lazy" />
|
||||
)}
|
||||
<span className="rb-fav-text">
|
||||
<span className="rb-fav-name">{f.name}</span>
|
||||
{f.note && <span className="rb-fav-note">{f.note}</span>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={i} className="rb-fav">
|
||||
{f.url ? (
|
||||
<a href={f.url} target="_blank" rel="noreferrer" className="rb-fav-link">
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
@@ -140,6 +166,15 @@ export default async function BioPage() {
|
||||
<ul className="rb-console-list">
|
||||
{profile.consoles.map((c, i) => (
|
||||
<li key={i} className="rb-console">
|
||||
{c.icon &&
|
||||
(isIconUrl(c.icon) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="rb-console-icon-img" src={c.icon} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="rb-console-icon" aria-hidden>
|
||||
{c.icon}
|
||||
</span>
|
||||
))}
|
||||
<span className="rb-console-name">{c.name}</span>
|
||||
{c.note && <span className="rb-console-note">{c.note}</span>}
|
||||
</li>
|
||||
|
||||
+34
-2
@@ -399,12 +399,44 @@ img {
|
||||
.rb-fav,
|
||||
.rb-console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid currentColor;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.rb-fav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.rb-fav-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.rb-fav-cover {
|
||||
width: 56px;
|
||||
height: 26px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.rb-console-icon {
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
}
|
||||
.rb-console-icon-img {
|
||||
height: 20px;
|
||||
width: auto;
|
||||
max-width: 96px;
|
||||
object-fit: contain;
|
||||
flex: none;
|
||||
}
|
||||
.rb-fav-name,
|
||||
.rb-console-name {
|
||||
font-weight: bold;
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DEFAULT_CONFIG: IntegrationConfig = {
|
||||
steam: { enabled: false, apiKey: "", steamId: "" },
|
||||
psn: { enabled: false, npsso: "" },
|
||||
xbox: { enabled: false, apiKey: "", xuid: "" },
|
||||
retro: { enabled: false, username: "", apiKey: "" },
|
||||
cacheTtlMinutes: 360,
|
||||
};
|
||||
|
||||
@@ -54,7 +55,12 @@ function normConsoles(input: unknown): ConsoleItem[] {
|
||||
return input
|
||||
.map((raw) => {
|
||||
const o = (raw ?? {}) as Record<string, unknown>;
|
||||
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
|
||||
return {
|
||||
id: str(o.id).trim() || undefined,
|
||||
name: str(o.name).trim(),
|
||||
icon: str(o.icon).trim() || undefined,
|
||||
note: str(o.note).trim() || undefined,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.name);
|
||||
}
|
||||
@@ -64,7 +70,12 @@ function normFavorites(input: unknown): FavoriteGame[] {
|
||||
return input
|
||||
.map((raw) => {
|
||||
const o = (raw ?? {}) as Record<string, unknown>;
|
||||
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined };
|
||||
return {
|
||||
name: str(o.name).trim(),
|
||||
image: str(o.image).trim() || undefined,
|
||||
url: str(o.url).trim() || undefined,
|
||||
note: str(o.note).trim() || undefined,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.name);
|
||||
}
|
||||
@@ -74,6 +85,7 @@ export function normalizeConfig(input: unknown): IntegrationConfig {
|
||||
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 retro = (o.retro ?? {}) as Record<string, unknown>;
|
||||
const ttl = Number(o.cacheTtlMinutes);
|
||||
return {
|
||||
displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
|
||||
@@ -96,6 +108,11 @@ export function normalizeConfig(input: unknown): IntegrationConfig {
|
||||
apiKey: str(xbox.apiKey).trim(),
|
||||
xuid: str(xbox.xuid).trim(),
|
||||
},
|
||||
retro: {
|
||||
enabled: bool(retro.enabled),
|
||||
username: str(retro.username).trim(),
|
||||
apiKey: str(retro.apiKey).trim(),
|
||||
},
|
||||
cacheTtlMinutes:
|
||||
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Registry of known gaming consoles / platforms. Powers the admin "console
|
||||
// picker": a searchable list the author clicks to build their owned-hardware
|
||||
// shelf. Kept free of any server-only imports so the picker (a client
|
||||
// component) can bundle and filter it locally for instant suggestions — same
|
||||
// philosophy as ./social.
|
||||
//
|
||||
// `icon`, when present, is a path to a self-hosted brand logo SVG under
|
||||
// /public/consoles (sourced from Wikimedia Commons / Simple Icons). Consoles
|
||||
// without a logo omit `icon` and render as plain text. Where a model has no
|
||||
// logo of its own, it falls back to its maker's mark (e.g. all Sega models use
|
||||
// the Sega logo). Renderers <img> any icon that looks like a path/URL.
|
||||
|
||||
export type ConsoleDef = {
|
||||
/** Stable slug, persisted on the saved item. */
|
||||
id: string;
|
||||
name: string;
|
||||
/** Maker — shown as a muted hint + used to weight search. */
|
||||
maker: string;
|
||||
/** Release year, for disambiguation in suggestions. */
|
||||
year?: number;
|
||||
/** Logo path (/consoles/*.svg) or URL. Absent → text-only. */
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
// Hand-curated, roughly chronological within each maker. Not exhaustive, but
|
||||
// covers the consoles a retro-leaning author is likely to list.
|
||||
export const CONSOLES: ConsoleDef[] = [
|
||||
// --- Nintendo ---
|
||||
{ id: "nes", name: "NES", maker: "Nintendo", year: 1983, icon: "/consoles/nes.svg" },
|
||||
{ id: "fds", name: "Famicom Disk System", maker: "Nintendo", year: 1986 },
|
||||
{ id: "snes", name: "Super Nintendo (SNES)", maker: "Nintendo", year: 1990, icon: "/consoles/snes.svg" },
|
||||
{ id: "n64", name: "Nintendo 64", maker: "Nintendo", year: 1996 },
|
||||
{ id: "gamecube", name: "GameCube", maker: "Nintendo", year: 2001 },
|
||||
{ id: "wii", name: "Wii", maker: "Nintendo", year: 2006, icon: "/consoles/wii.svg" },
|
||||
{ id: "wiiu", name: "Wii U", maker: "Nintendo", year: 2012, icon: "/consoles/wiiu.svg" },
|
||||
{ id: "switch", name: "Nintendo Switch", maker: "Nintendo", year: 2017, icon: "/consoles/switch.svg" },
|
||||
{ id: "gameboy", name: "Game Boy", maker: "Nintendo", year: 1989, icon: "/consoles/gameboy.svg" },
|
||||
{ id: "gbc", name: "Game Boy Color", maker: "Nintendo", year: 1998, icon: "/consoles/gbc.svg" },
|
||||
{ id: "gba", name: "Game Boy Advance", maker: "Nintendo", year: 2001, icon: "/consoles/gba.svg" },
|
||||
{ id: "nds", name: "Nintendo DS", maker: "Nintendo", year: 2004, icon: "/consoles/nds.svg" },
|
||||
{ id: "3ds", name: "Nintendo 3DS", maker: "Nintendo", year: 2011, icon: "/consoles/3ds.svg" },
|
||||
{ id: "virtualboy", name: "Virtual Boy", maker: "Nintendo", year: 1995 },
|
||||
|
||||
// --- Sega (models without their own logo fall back to the Sega mark) ---
|
||||
{ id: "sg1000", name: "SG-1000", maker: "Sega", year: 1983, icon: "/consoles/sega.svg" },
|
||||
{ id: "mastersystem", name: "Master System", maker: "Sega", year: 1985, icon: "/consoles/sega.svg" },
|
||||
{ id: "genesis", name: "Genesis / Mega Drive", maker: "Sega", year: 1988, icon: "/consoles/sega.svg" },
|
||||
{ id: "segacd", name: "Sega CD", maker: "Sega", year: 1991, icon: "/consoles/sega.svg" },
|
||||
{ id: "saturn", name: "Sega Saturn", maker: "Sega", year: 1994, icon: "/consoles/sega.svg" },
|
||||
{ id: "dreamcast", name: "Dreamcast", maker: "Sega", year: 1998, icon: "/consoles/dreamcast.svg" },
|
||||
{ id: "gamegear", name: "Game Gear", maker: "Sega", year: 1990, icon: "/consoles/sega.svg" },
|
||||
|
||||
// --- Sony ---
|
||||
{ id: "ps1", name: "PlayStation", maker: "Sony", year: 1994, icon: "/consoles/ps1.svg" },
|
||||
{ id: "ps2", name: "PlayStation 2", maker: "Sony", year: 2000, icon: "/consoles/ps2.svg" },
|
||||
{ id: "ps3", name: "PlayStation 3", maker: "Sony", year: 2006, icon: "/consoles/ps3.svg" },
|
||||
{ id: "ps4", name: "PlayStation 4", maker: "Sony", year: 2013, icon: "/consoles/ps4.svg" },
|
||||
{ id: "ps5", name: "PlayStation 5", maker: "Sony", year: 2020, icon: "/consoles/ps5.svg" },
|
||||
{ id: "psp", name: "PSP", maker: "Sony", year: 2004, icon: "/consoles/psp.svg" },
|
||||
{ id: "psvita", name: "PS Vita", maker: "Sony", year: 2011, icon: "/consoles/psvita.svg" },
|
||||
|
||||
// --- Microsoft (360 / Series fall back to the Xbox mark) ---
|
||||
{ id: "xbox", name: "Xbox", maker: "Microsoft", year: 2001, icon: "/consoles/xbox.svg" },
|
||||
{ id: "xbox360", name: "Xbox 360", maker: "Microsoft", year: 2005, icon: "/consoles/xbox.svg" },
|
||||
{ id: "xboxone", name: "Xbox One", maker: "Microsoft", year: 2013, icon: "/consoles/xboxone.svg" },
|
||||
{ id: "xboxseries", name: "Xbox Series X|S", maker: "Microsoft", year: 2020, icon: "/consoles/xbox.svg" },
|
||||
|
||||
// --- Atari (all fall back to the Atari mark) ---
|
||||
{ id: "atari2600", name: "Atari 2600", maker: "Atari", year: 1977, icon: "/consoles/atari2600.svg" },
|
||||
{ id: "atari5200", name: "Atari 5200", maker: "Atari", year: 1982, icon: "/consoles/atari2600.svg" },
|
||||
{ id: "atari7800", name: "Atari 7800", maker: "Atari", year: 1986, icon: "/consoles/atari2600.svg" },
|
||||
{ id: "lynx", name: "Atari Lynx", maker: "Atari", year: 1989, icon: "/consoles/atari2600.svg" },
|
||||
{ id: "jaguar", name: "Atari Jaguar", maker: "Atari", year: 1993, icon: "/consoles/atari2600.svg" },
|
||||
|
||||
// --- Other consoles / handhelds ---
|
||||
{ id: "neogeo", name: "Neo Geo (AES)", maker: "SNK", year: 1990 },
|
||||
{ id: "ngpc", name: "Neo Geo Pocket Color", maker: "SNK", year: 1999 },
|
||||
{ id: "turbografx16", name: "TurboGrafx-16 / PC Engine", maker: "NEC", year: 1987 },
|
||||
{ id: "3do", name: "3DO", maker: "Panasonic", year: 1993 },
|
||||
{ id: "colecovision", name: "ColecoVision", maker: "Coleco", year: 1982 },
|
||||
{ id: "intellivision", name: "Intellivision", maker: "Mattel", year: 1979 },
|
||||
{ id: "wonderswan", name: "WonderSwan", maker: "Bandai", year: 1999 },
|
||||
{ id: "steamdeck", name: "Steam Deck", maker: "Valve", year: 2022, icon: "/consoles/steamdeck.svg" },
|
||||
{ id: "ouya", name: "Ouya", maker: "Ouya", year: 2013 },
|
||||
|
||||
// --- Computers ---
|
||||
{ id: "pc", name: "PC", maker: "Microsoft Windows" },
|
||||
{ id: "mac", name: "Mac", maker: "Apple", icon: "/consoles/mac.svg" },
|
||||
{ id: "linux", name: "Linux", maker: "GNU/Linux" },
|
||||
{ id: "c64", name: "Commodore 64", maker: "Commodore", year: 1982, icon: "/consoles/commodore.svg" },
|
||||
{ id: "amiga", name: "Amiga", maker: "Commodore", year: 1985, icon: "/consoles/commodore.svg" },
|
||||
{ id: "msx", name: "MSX", maker: "Microsoft / ASCII", year: 1983 },
|
||||
{ id: "zxspectrum", name: "ZX Spectrum", maker: "Sinclair", year: 1982 },
|
||||
{ id: "dos", name: "MS-DOS", maker: "Microsoft", year: 1981 },
|
||||
{ id: "arcade", name: "Arcade", maker: "Various" },
|
||||
];
|
||||
|
||||
const BY_ID = new Map(CONSOLES.map((c) => [c.id, c]));
|
||||
|
||||
export function isConsoleId(v: unknown): v is string {
|
||||
return typeof v === "string" && BY_ID.has(v);
|
||||
}
|
||||
|
||||
export function consoleById(id: string): ConsoleDef | undefined {
|
||||
return BY_ID.get(id);
|
||||
}
|
||||
|
||||
/** True when an icon string should render as an <img> rather than text. */
|
||||
export function isIconUrl(icon: string): boolean {
|
||||
return /^(https?:\/\/|\/)/.test(icon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the registry for the picker. Matches name + maker, ranks prefix hits
|
||||
* first, and caps results so the suggestion list stays tight.
|
||||
*/
|
||||
export function searchConsoles(query: string, limit = 8): ConsoleDef[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return CONSOLES.slice(0, limit);
|
||||
const scored: { c: ConsoleDef; score: number }[] = [];
|
||||
for (const c of CONSOLES) {
|
||||
const name = c.name.toLowerCase();
|
||||
const maker = c.maker.toLowerCase();
|
||||
let score = -1;
|
||||
if (name.startsWith(q)) score = 3;
|
||||
else if (name.includes(q)) score = 2;
|
||||
else if (maker.includes(q)) score = 1;
|
||||
if (score >= 0) scored.push({ c, score });
|
||||
}
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score || a.c.name.localeCompare(b.c.name))
|
||||
.slice(0, limit)
|
||||
.map((s) => s.c);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getIntegrationConfig } from "./config";
|
||||
import { fetchSteam } from "./steam";
|
||||
import { fetchPsn } from "./psn";
|
||||
import { fetchXbox } from "./xbox";
|
||||
import { fetchRetro } from "./retro";
|
||||
import type {
|
||||
Game,
|
||||
IntegrationConfig,
|
||||
@@ -20,6 +21,7 @@ const CACHE_KEYS: Record<PlatformId, string> = {
|
||||
steam: "platform:steam",
|
||||
psn: "platform:psn",
|
||||
xbox: "platform:xbox",
|
||||
retro: "platform:retro",
|
||||
};
|
||||
|
||||
function emptyPlatform(platform: PlatformId): PlatformData {
|
||||
@@ -45,6 +47,9 @@ async function loadPlatform(
|
||||
case "xbox":
|
||||
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
|
||||
break;
|
||||
case "retro":
|
||||
result = await cached(key, ttl, fallback, () => fetchRetro(cfg.retro));
|
||||
break;
|
||||
}
|
||||
// Surface a cache-layer error onto the payload if the fetch's own error is unset.
|
||||
return result.error && !result.data.error
|
||||
@@ -57,6 +62,7 @@ function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
|
||||
if (cfg.steam.enabled) out.push("steam");
|
||||
if (cfg.psn.enabled) out.push("psn");
|
||||
if (cfg.xbox.enabled) out.push("xbox");
|
||||
if (cfg.retro.enabled) out.push("retro");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -125,3 +131,34 @@ export function hasIntegrations(): boolean {
|
||||
export function refreshPlatforms(): void {
|
||||
clearCache(Object.values(CACHE_KEYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one platform's fetcher directly (no cache) against an arbitrary config —
|
||||
* powers the admin "Test connection" buttons, which validate live form values
|
||||
* before anything is saved. Returns the payload (with `.error` set on failure).
|
||||
*/
|
||||
export async function testPlatform(
|
||||
platform: PlatformId,
|
||||
cfg: IntegrationConfig
|
||||
): Promise<PlatformData> {
|
||||
try {
|
||||
switch (platform) {
|
||||
case "steam":
|
||||
return await fetchSteam({ ...cfg.steam, enabled: true });
|
||||
case "psn":
|
||||
return await fetchPsn({ ...cfg.psn, enabled: true });
|
||||
case "xbox":
|
||||
return await fetchXbox({ ...cfg.xbox, enabled: true });
|
||||
case "retro":
|
||||
return await fetchRetro({ ...cfg.retro, enabled: true });
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
platform,
|
||||
games: [],
|
||||
achievements: [],
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import "server-only";
|
||||
import type { Achievement, Game, PlatformData, RetroConfig } from "./types";
|
||||
|
||||
// RetroAchievements has a clean, official, key-based Web API. We pull the user's
|
||||
// recently-played games and their recent achievement unlocks. Auth is two query
|
||||
// params on every call: z = username, y = web API key.
|
||||
// Docs: https://api-docs.retroachievements.org
|
||||
|
||||
const API = "https://retroachievements.org/API";
|
||||
const MEDIA = "https://media.retroachievements.org";
|
||||
const TIMEOUT = 8000;
|
||||
|
||||
function media(path?: string): string | undefined {
|
||||
if (!path) return undefined;
|
||||
return path.startsWith("http") ? path : `${MEDIA}${path}`;
|
||||
}
|
||||
|
||||
async function getJson(path: string, cfg: RetroConfig): Promise<unknown> {
|
||||
const auth = `z=${encodeURIComponent(cfg.username)}&y=${encodeURIComponent(cfg.apiKey)}`;
|
||||
const url = `${API}/${path}${path.includes("?") ? "&" : "?"}${auth}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
|
||||
if (!res.ok) throw new Error(`RetroAchievements ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
type RecentGame = {
|
||||
GameID: number;
|
||||
Title: string;
|
||||
ImageIcon?: string;
|
||||
ConsoleName?: string;
|
||||
LastPlayed?: string;
|
||||
NumAchieved?: number;
|
||||
};
|
||||
|
||||
type RecentAch = {
|
||||
AchievementID: number;
|
||||
Title: string;
|
||||
Description?: string;
|
||||
BadgeName?: string;
|
||||
GameTitle?: string;
|
||||
ConsoleName?: string;
|
||||
Date?: string;
|
||||
};
|
||||
|
||||
export async function fetchRetro(cfg: RetroConfig): Promise<PlatformData> {
|
||||
const base: PlatformData = {
|
||||
platform: "retro",
|
||||
games: [],
|
||||
achievements: [],
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
if (!cfg.enabled) return base;
|
||||
if (!cfg.username || !cfg.apiKey) {
|
||||
return { ...base, error: "RetroAchievements username and API key are required." };
|
||||
}
|
||||
|
||||
const recent = (await getJson(
|
||||
`API_GetUserRecentlyPlayedGames.php?u=${encodeURIComponent(cfg.username)}&c=12`,
|
||||
cfg
|
||||
)) as RecentGame[];
|
||||
|
||||
const games: Game[] = (recent ?? []).map((g) => ({
|
||||
platform: "retro",
|
||||
name: g.ConsoleName ? `${g.Title} (${g.ConsoleName})` : g.Title,
|
||||
image: media(g.ImageIcon),
|
||||
url: `https://retroachievements.org/game/${g.GameID}`,
|
||||
lastPlayed: g.LastPlayed ? g.LastPlayed.replace(" ", "T") + "Z" : undefined,
|
||||
}));
|
||||
|
||||
// Recent achievement unlocks across the last ~2 weeks (m = minutes back).
|
||||
let achievements: Achievement[] = [];
|
||||
try {
|
||||
const ach = (await getJson(
|
||||
`API_GetUserRecentAchievements.php?u=${encodeURIComponent(cfg.username)}&m=20160`,
|
||||
cfg
|
||||
)) as RecentAch[];
|
||||
achievements = (ach ?? []).slice(0, 8).map((a) => ({
|
||||
platform: "retro" as const,
|
||||
game: a.GameTitle ?? "RetroAchievements",
|
||||
name: a.Title,
|
||||
description: a.Description,
|
||||
icon: a.BadgeName ? `${MEDIA}/Badge/${a.BadgeName}.png` : undefined,
|
||||
unlockedAt: a.Date ? a.Date.replace(" ", "T") + "Z" : undefined,
|
||||
}));
|
||||
} catch {
|
||||
/* achievements optional; keep the games */
|
||||
}
|
||||
|
||||
return { ...base, games, achievements };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type SocialNetworkId =
|
||||
| "steam"
|
||||
| "psn"
|
||||
| "xbox"
|
||||
| "retroachievements"
|
||||
| "rss"
|
||||
| "website";
|
||||
|
||||
@@ -42,6 +43,7 @@ export const SOCIAL_NETWORKS: SocialNetwork[] = [
|
||||
{ id: "steam", name: "Steam", icon: "🕹️" },
|
||||
{ id: "psn", name: "PlayStation", icon: "🎯" },
|
||||
{ id: "xbox", name: "Xbox", icon: "🟢" },
|
||||
{ id: "retroachievements", name: "RetroAchievements", icon: "🏆" },
|
||||
{ id: "rss", name: "RSS", icon: "📡" },
|
||||
{ id: "website", name: "Website", icon: "🌐" },
|
||||
];
|
||||
|
||||
@@ -4,13 +4,18 @@ 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
|
||||
// We pull the recently-played games (last 2 weeks). If the account hasn't been
|
||||
// played recently that list is empty, so we fall back to the full owned-games
|
||||
// library sorted by last-played. We 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;
|
||||
|
||||
// Thrown when the Steam account's "Game details" privacy hides achievement data.
|
||||
class PrivacyError extends Error {}
|
||||
|
||||
function header(appid: number): string {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
|
||||
}
|
||||
@@ -34,6 +39,20 @@ async function recentGames(key: string, steamId: string): Promise<RecentGame[]>
|
||||
return json.response?.games ?? [];
|
||||
}
|
||||
|
||||
type OwnedGame = RecentGame & { rtime_last_played?: number };
|
||||
|
||||
// Fallback when nothing was played in the last 2 weeks: the whole library,
|
||||
// sorted by most-recently-played, trimmed to the same count as the recent list.
|
||||
async function ownedGames(key: string, steamId: string): Promise<RecentGame[]> {
|
||||
const url = `${API}/IPlayerService/GetOwnedGames/v1/?key=${key}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json`;
|
||||
const json = (await getJson(url)) as { response?: { games?: OwnedGame[] } };
|
||||
const games = json.response?.games ?? [];
|
||||
return games
|
||||
.slice()
|
||||
.sort((a, b) => (b.rtime_last_played ?? 0) - (a.rtime_last_played ?? 0))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string };
|
||||
|
||||
async function achievementsFor(
|
||||
@@ -47,10 +66,16 @@ async function achievementsFor(
|
||||
let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
|
||||
try {
|
||||
const json = (await getJson(progUrl)) as {
|
||||
playerstats?: { achievements?: typeof progress };
|
||||
playerstats?: { achievements?: typeof progress; success?: boolean; error?: string };
|
||||
};
|
||||
// success=false with a privacy error means the account's "Game details"
|
||||
// visibility is not Public — distinct from a game that has no achievements.
|
||||
if (json.playerstats?.success === false && /not public/i.test(json.playerstats.error ?? "")) {
|
||||
throw new PrivacyError();
|
||||
}
|
||||
progress = json.playerstats?.achievements ?? [];
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PrivacyError) throw e;
|
||||
return []; // many games expose no achievements; treat as none
|
||||
}
|
||||
|
||||
@@ -97,7 +122,10 @@ export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
|
||||
return { ...base, error: "Steam API key and SteamID are required." };
|
||||
}
|
||||
|
||||
const recent = await recentGames(cfg.apiKey, cfg.steamId);
|
||||
let recent = await recentGames(cfg.apiKey, cfg.steamId);
|
||||
if (recent.length === 0) {
|
||||
recent = await ownedGames(cfg.apiKey, cfg.steamId);
|
||||
}
|
||||
const games: Game[] = recent.map((g) => ({
|
||||
platform: "steam",
|
||||
name: g.name,
|
||||
@@ -106,15 +134,24 @@ export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
|
||||
playtimeMinutes: g.playtime_forever,
|
||||
}));
|
||||
|
||||
// Probe the most-recent games in order until one yields unlocked
|
||||
// achievements — many titles (e.g. older or MP games) expose none. Bounded so
|
||||
// the request count stays small.
|
||||
let achievements: Achievement[] = [];
|
||||
if (recent[0]) {
|
||||
achievements = await achievementsFor(
|
||||
cfg.apiKey,
|
||||
cfg.steamId,
|
||||
recent[0].appid,
|
||||
recent[0].name
|
||||
);
|
||||
let notice: string | undefined;
|
||||
for (const g of recent.slice(0, 5)) {
|
||||
try {
|
||||
achievements = await achievementsFor(cfg.apiKey, cfg.steamId, g.appid, g.name);
|
||||
} catch (e) {
|
||||
if (e instanceof PrivacyError) {
|
||||
notice =
|
||||
'Games loaded, but achievements are hidden by Steam privacy. Set Steam → Profile → Edit Profile → Privacy → "Game details" to Public.';
|
||||
break;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (achievements.length > 0) break;
|
||||
}
|
||||
|
||||
return { ...base, games, achievements };
|
||||
return { ...base, games, achievements, notice };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type SocialLink = {
|
||||
};
|
||||
|
||||
/** The platforms we can auto-fetch gameplay data from. */
|
||||
export type PlatformId = "steam" | "psn" | "xbox";
|
||||
export type PlatformId = "steam" | "psn" | "xbox" | "retro";
|
||||
|
||||
/** A game surfaced from a platform's "recently played" / activity feed. */
|
||||
export type Game = {
|
||||
@@ -44,13 +44,21 @@ export type Achievement = {
|
||||
|
||||
/** A console the author owns / has owned (curated by hand). */
|
||||
export type ConsoleItem = {
|
||||
/** Registry id when picked from the known-consoles list; absent if free-form. */
|
||||
id?: string;
|
||||
name: string;
|
||||
/** Emoji glyph or image URL, denormalized from the registry for rendering. */
|
||||
icon?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** An all-time favorite game (curated by hand). */
|
||||
export type FavoriteGame = {
|
||||
name: string;
|
||||
/** Cover / capsule art URL, when added from the game search. */
|
||||
image?: string;
|
||||
/** Store / info page link. */
|
||||
url?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
@@ -76,6 +84,14 @@ export type XboxConfig = {
|
||||
xuid: string;
|
||||
};
|
||||
|
||||
export type RetroConfig = {
|
||||
enabled: boolean;
|
||||
/** RetroAchievements username (also used as the API caller). */
|
||||
username: string;
|
||||
/** Web API key from retroachievements.org/settings. */
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
/** Everything the admin curates + the platform credentials. Holds secrets. */
|
||||
export type IntegrationConfig = {
|
||||
displayName: string;
|
||||
@@ -88,6 +104,7 @@ export type IntegrationConfig = {
|
||||
steam: SteamConfig;
|
||||
psn: PsnConfig;
|
||||
xbox: XboxConfig;
|
||||
retro: RetroConfig;
|
||||
/** Cache freshness window for platform fetches, in minutes. */
|
||||
cacheTtlMinutes: number;
|
||||
};
|
||||
@@ -99,6 +116,8 @@ export type PlatformData = {
|
||||
achievements: Achievement[];
|
||||
/** Human-readable error if the last fetch failed (data may be stale/empty). */
|
||||
error?: string;
|
||||
/** Non-fatal hint (e.g. games loaded but achievements are privacy-blocked). */
|
||||
notice?: string;
|
||||
/** ISO timestamp of when this payload was fetched. */
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user