From c8ba511ebbb1a68b301df711af00dbc8d3916185 Mon Sep 17 00:00:00 2001 From: kawa Date: Sun, 7 Jun 2026 05:43:06 +0200 Subject: [PATCH] Added easy drop-down to add consoles and games to bio --- public/consoles/3ds.svg | 33 +++ public/consoles/atari2600.svg | 1 + public/consoles/commodore.svg | 1 + public/consoles/dreamcast.svg | 81 ++++++ public/consoles/gameboy.svg | 23 ++ public/consoles/gba.svg | 40 +++ public/consoles/gbc.svg | 75 +++++ public/consoles/linux.svg | 1 + public/consoles/mac.svg | 3 + public/consoles/nds.svg | 72 +++++ public/consoles/nes.svg | 221 ++++++++++++++ public/consoles/pc.svg | 1 + public/consoles/ps1.svg | 1 + public/consoles/ps2.svg | 1 + public/consoles/ps3.svg | 1 + public/consoles/ps4.svg | 1 + public/consoles/ps5.svg | 1 + public/consoles/psp.svg | 1 + public/consoles/psvita.svg | 1 + public/consoles/sega.svg | 1 + public/consoles/snes.svg | 60 ++++ public/consoles/steamdeck.svg | 1 + public/consoles/switch.svg | 76 +++++ public/consoles/wii.svg | 15 + public/consoles/wiiu.svg | 6 + public/consoles/xbox.svg | 1 + public/consoles/xboxone.svg | 2 + .../(panel)/integrations/ConsolePicker.tsx | 153 ++++++++++ .../admin/(panel)/integrations/GamePicker.tsx | 168 +++++++++++ .../integrations/games/search/route.ts | 42 +++ src/app/admin/(panel)/integrations/page.tsx | 274 +++++++++++++++--- .../(panel)/integrations/steam/link/route.ts | 27 ++ .../integrations/steam/return/route.ts | 53 ++++ src/app/admin/actions.ts | 89 +++++- src/app/admin/admin.css | 215 ++++++++++++++ src/app/bio/page.tsx | 49 +++- src/app/globals.css | 36 ++- src/lib/integrations/config.ts | 21 +- src/lib/integrations/consoles.ts | 134 +++++++++ src/lib/integrations/index.ts | 37 +++ src/lib/integrations/retro.ts | 90 ++++++ src/lib/integrations/social.ts | 2 + src/lib/integrations/steam.ts | 61 +++- src/lib/integrations/types.ts | 21 +- 44 files changed, 2111 insertions(+), 82 deletions(-) create mode 100644 public/consoles/3ds.svg create mode 100644 public/consoles/atari2600.svg create mode 100644 public/consoles/commodore.svg create mode 100644 public/consoles/dreamcast.svg create mode 100644 public/consoles/gameboy.svg create mode 100644 public/consoles/gba.svg create mode 100644 public/consoles/gbc.svg create mode 100644 public/consoles/linux.svg create mode 100644 public/consoles/mac.svg create mode 100644 public/consoles/nds.svg create mode 100644 public/consoles/nes.svg create mode 100644 public/consoles/pc.svg create mode 100644 public/consoles/ps1.svg create mode 100644 public/consoles/ps2.svg create mode 100644 public/consoles/ps3.svg create mode 100644 public/consoles/ps4.svg create mode 100644 public/consoles/ps5.svg create mode 100644 public/consoles/psp.svg create mode 100644 public/consoles/psvita.svg create mode 100644 public/consoles/sega.svg create mode 100644 public/consoles/snes.svg create mode 100644 public/consoles/steamdeck.svg create mode 100644 public/consoles/switch.svg create mode 100644 public/consoles/wii.svg create mode 100644 public/consoles/wiiu.svg create mode 100644 public/consoles/xbox.svg create mode 100644 public/consoles/xboxone.svg create mode 100644 src/app/admin/(panel)/integrations/ConsolePicker.tsx create mode 100644 src/app/admin/(panel)/integrations/GamePicker.tsx create mode 100644 src/app/admin/(panel)/integrations/games/search/route.ts create mode 100644 src/app/admin/(panel)/integrations/steam/link/route.ts create mode 100644 src/app/admin/(panel)/integrations/steam/return/route.ts create mode 100644 src/lib/integrations/consoles.ts create mode 100644 src/lib/integrations/retro.ts diff --git a/public/consoles/3ds.svg b/public/consoles/3ds.svg new file mode 100644 index 0000000..250a6e7 --- /dev/null +++ b/public/consoles/3ds.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + diff --git a/public/consoles/atari2600.svg b/public/consoles/atari2600.svg new file mode 100644 index 0000000..567c4f2 --- /dev/null +++ b/public/consoles/atari2600.svg @@ -0,0 +1 @@ +Atari \ No newline at end of file diff --git a/public/consoles/commodore.svg b/public/consoles/commodore.svg new file mode 100644 index 0000000..37a302f --- /dev/null +++ b/public/consoles/commodore.svg @@ -0,0 +1 @@ +Commodore \ No newline at end of file diff --git a/public/consoles/dreamcast.svg b/public/consoles/dreamcast.svg new file mode 100644 index 0000000..ee49e37 --- /dev/null +++ b/public/consoles/dreamcast.svg @@ -0,0 +1,81 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/public/consoles/gameboy.svg b/public/consoles/gameboy.svg new file mode 100644 index 0000000..2385d46 --- /dev/null +++ b/public/consoles/gameboy.svg @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/public/consoles/gba.svg b/public/consoles/gba.svg new file mode 100644 index 0000000..fcc63c2 --- /dev/null +++ b/public/consoles/gba.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + diff --git a/public/consoles/gbc.svg b/public/consoles/gbc.svg new file mode 100644 index 0000000..1b32fda --- /dev/null +++ b/public/consoles/gbc.svg @@ -0,0 +1,75 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/public/consoles/linux.svg b/public/consoles/linux.svg new file mode 100644 index 0000000..58be2b6 --- /dev/null +++ b/public/consoles/linux.svg @@ -0,0 +1 @@ +Linux \ No newline at end of file diff --git a/public/consoles/mac.svg b/public/consoles/mac.svg new file mode 100644 index 0000000..82b0cdd --- /dev/null +++ b/public/consoles/mac.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/consoles/nds.svg b/public/consoles/nds.svg new file mode 100644 index 0000000..7d3f29f --- /dev/null +++ b/public/consoles/nds.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/consoles/nes.svg b/public/consoles/nes.svg new file mode 100644 index 0000000..c236b4c --- /dev/null +++ b/public/consoles/nes.svg @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/consoles/pc.svg b/public/consoles/pc.svg new file mode 100644 index 0000000..2c7392e --- /dev/null +++ b/public/consoles/pc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/consoles/ps1.svg b/public/consoles/ps1.svg new file mode 100644 index 0000000..6e544ae --- /dev/null +++ b/public/consoles/ps1.svg @@ -0,0 +1 @@ +PlayStation \ No newline at end of file diff --git a/public/consoles/ps2.svg b/public/consoles/ps2.svg new file mode 100644 index 0000000..38a53bd --- /dev/null +++ b/public/consoles/ps2.svg @@ -0,0 +1 @@ +PlayStation 2 \ No newline at end of file diff --git a/public/consoles/ps3.svg b/public/consoles/ps3.svg new file mode 100644 index 0000000..7a07f7d --- /dev/null +++ b/public/consoles/ps3.svg @@ -0,0 +1 @@ +PlayStation 3 \ No newline at end of file diff --git a/public/consoles/ps4.svg b/public/consoles/ps4.svg new file mode 100644 index 0000000..32dd637 --- /dev/null +++ b/public/consoles/ps4.svg @@ -0,0 +1 @@ +PlayStation 4 \ No newline at end of file diff --git a/public/consoles/ps5.svg b/public/consoles/ps5.svg new file mode 100644 index 0000000..4d377d5 --- /dev/null +++ b/public/consoles/ps5.svg @@ -0,0 +1 @@ +PlayStation 5 \ No newline at end of file diff --git a/public/consoles/psp.svg b/public/consoles/psp.svg new file mode 100644 index 0000000..7d8d73c --- /dev/null +++ b/public/consoles/psp.svg @@ -0,0 +1 @@ +PlayStation Portable \ No newline at end of file diff --git a/public/consoles/psvita.svg b/public/consoles/psvita.svg new file mode 100644 index 0000000..05c37c0 --- /dev/null +++ b/public/consoles/psvita.svg @@ -0,0 +1 @@ +PlayStation Vita \ No newline at end of file diff --git a/public/consoles/sega.svg b/public/consoles/sega.svg new file mode 100644 index 0000000..ec131db --- /dev/null +++ b/public/consoles/sega.svg @@ -0,0 +1 @@ +Sega \ No newline at end of file diff --git a/public/consoles/snes.svg b/public/consoles/snes.svg new file mode 100644 index 0000000..cc12eb8 --- /dev/null +++ b/public/consoles/snes.svg @@ -0,0 +1,60 @@ + + + + + + + image/svg+xml + + + + + + + SVG drawing + This was produced by version 4.2 of GNU libplot, a free library for exporting 2-D vector graphics. + + diff --git a/public/consoles/steamdeck.svg b/public/consoles/steamdeck.svg new file mode 100644 index 0000000..493b8dd --- /dev/null +++ b/public/consoles/steamdeck.svg @@ -0,0 +1 @@ +Steam Deck \ No newline at end of file diff --git a/public/consoles/switch.svg b/public/consoles/switch.svg new file mode 100644 index 0000000..345db17 --- /dev/null +++ b/public/consoles/switch.svg @@ -0,0 +1,76 @@ + + + + +Created by potrace 1.13, written by Peter Selinger 2001-2015 + + + + + + + + + + + + + + + + + + + + + diff --git a/public/consoles/wii.svg b/public/consoles/wii.svg new file mode 100644 index 0000000..fdfb93a --- /dev/null +++ b/public/consoles/wii.svg @@ -0,0 +1,15 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/public/consoles/wiiu.svg b/public/consoles/wiiu.svg new file mode 100644 index 0000000..12eff58 --- /dev/null +++ b/public/consoles/wiiu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/consoles/xbox.svg b/public/consoles/xbox.svg new file mode 100644 index 0000000..f88c287 --- /dev/null +++ b/public/consoles/xbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/consoles/xboxone.svg b/public/consoles/xboxone.svg new file mode 100644 index 0000000..3a16b88 --- /dev/null +++ b/public/consoles/xboxone.svg @@ -0,0 +1,2 @@ + +Xbox Logo \ No newline at end of file diff --git a/src/app/admin/(panel)/integrations/ConsolePicker.tsx b/src/app/admin/(panel)/integrations/ConsolePicker.tsx new file mode 100644 index 0000000..0e1ad4d --- /dev/null +++ b/src/app/admin/(panel)/integrations/ConsolePicker.tsx @@ -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 🎮; + if (isIconUrl(icon)) + // eslint-disable-next-line @next/next/no-img-element + return ; + return ( + + {icon} + + ); +} + +export default function ConsolePicker({ + name, + initial, +}: { + name: string; + initial: ConsoleItem[]; +}) { + const [items, setItems] = useState(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 ( +
+ + +
+ { + 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()) && ( +
    + {suggestions.map((c) => ( +
  • + +
  • + ))} + {query.trim() && ( +
  • + +
  • + )} +
+ )} +
+ + {items.length > 0 && ( +
    + {items.map((it, i) => ( +
  • + + {it.name} + setNote(i, e.target.value)} + /> + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/(panel)/integrations/GamePicker.tsx b/src/app/admin/(panel)/integrations/GamePicker.tsx new file mode 100644 index 0000000..5e0e82a --- /dev/null +++ b/src/app/admin/(panel)/integrations/GamePicker.tsx @@ -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 🎲; + // eslint-disable-next-line @next/next/no-img-element + return ; +} + +export default function GamePicker({ + name, + initial, +}: { + name: string; + initial: FavoriteGame[]; +}) { + const [items, setItems] = useState(initial); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const abortRef = useRef(null); + const timerRef = useRef | 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 ( +
+ + +
+ 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 && ( +
    + {loading && results.length === 0 && ( +
  • Searching…
  • + )} + {results.map((g, i) => ( +
  • + +
  • + ))} +
  • + +
  • +
+ )} +
+ + {items.length > 0 && ( +
    + {items.map((it, i) => ( +
  • + + {it.name} + setNote(i, e.target.value)} + /> + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/(panel)/integrations/games/search/route.ts b/src/app/admin/(panel)/integrations/games/search/route.ts new file mode 100644 index 0000000..ac09f18 --- /dev/null +++ b/src/app/admin/(panel)/integrations/games/search/route.ts @@ -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." }); + } +} diff --git a/src/app/admin/(panel)/integrations/page.tsx b/src/app/admin/(panel)/integrations/page.tsx index b63c7b1..2db0cdf 100644 --- a/src/app/admin/(panel)/integrations/page.tsx +++ b/src/app/admin/(panel)/integrations/page.tsx @@ -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
so it works with zero JS and stays readable on mobile. +function CredHelp({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ ⓘ {title} +
{children}
+
+ ); +} + +// 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 ( + + ); +} 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

Integrations saved. Caches cleared.

; - if (refreshed) return

Platform caches cleared — data refetches on next view.

; +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

Integrations saved. Caches cleared.

; + if (p.refreshed) + return

Platform caches cleared — data refetches on next view.

; + if (p.steam) + return ( +

+ Steam linked — SteamID {p.steam} saved. Add your API key below to pull + game data. +

+ ); + if (p.steamErr) return

{p.steamErr}

; + if (p.test && p.testErr) + return ( +

+ {p.test.toUpperCase()} test failed: {p.testErr} +

+ ); + if (p.test) + return ( +

+ {p.test.toUpperCase()} connection OK — fetched {p.testGames ?? 0} game(s). + {p.testNotice ? ` ${p.testNotice}` : ""} +

+ ); 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

-
-