+ );
return null;
}
export default async function IntegrationsPage({
searchParams,
}: {
- searchParams: Promise<{ saved?: string; refreshed?: string }>;
+ searchParams: Promise;
}) {
- 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 (
Integrations
-
+
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 ---- */}
Collection
-
-
-
+
+
+ Consoles owned{" "}
+ — search and click to add
+
+
+
+
+
+ Favorite games{" "}
+ — search (Steam catalog) and click to add
+
+
+ One-click:{" "}
+
+ Sign in with Steam
+ {" "}
+ to fill your SteamID automatically{cfg.steam.steamId ? ` (current: ${cfg.steam.steamId})` : ""}.
+ You still add an API key below for game data.
+
+
+
+
PlayStation (PSN)
+
+
+
Xbox
+
+
+
+
+
RetroAchievements
+
+
+
+
+
+
+
+
{/* ---- cache ---- */}
Caching
diff --git a/src/app/admin/(panel)/integrations/steam/link/route.ts b/src/app/admin/(panel)/integrations/steam/link/route.ts
new file mode 100644
index 0000000..07e5273
--- /dev/null
+++ b/src/app/admin/(panel)/integrations/steam/link/route.ts
@@ -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}`);
+}
diff --git a/src/app/admin/(panel)/integrations/steam/return/route.ts b/src/app/admin/(panel)/integrations/steam/return/route.ts
new file mode 100644
index 0000000..a177c0a
--- /dev/null
+++ b/src/app/admin/(panel)/integrations/steam/return/route.ts
@@ -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): 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 });
+}
diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts
index f840508..1556b42 100644
--- a/src/app/admin/actions.ts
+++ b/src/app/admin/actions.ts
@@ -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[] {
+ if (!raw.trim()) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? (parsed as Record[]) : [];
+ } 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();
diff --git a/src/app/admin/admin.css b/src/app/admin/admin.css
index 57ed41a..930d2f9 100644
--- a/src/app/admin/admin.css
+++ b/src/app/admin/admin.css
@@ -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);
+}
diff --git a/src/app/bio/page.tsx b/src/app/bio/page.tsx
index d5de7e6..85e6fee 100644
--- a/src/app/bio/page.tsx
+++ b/src/app/bio/page.tsx
@@ -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 = {
+ 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() {