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;
|
||||
|
||||
Reference in New Issue
Block a user