From d2e976a14529acded3efb87797782737fad1a954 Mon Sep 17 00:00:00 2001 From: roormonger <34205054+roormonger@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:37:24 -0400 Subject: [PATCH] game page refinements --- src/api/client.ts | 146 +++++++++++++++++++++++++++++-- src/components/GamesPage.tsx | 107 +++++++++++++++------- src/components/TopNav.tsx | 4 +- src/context/InputModeContext.tsx | 25 ++++-- 4 files changed, 232 insertions(+), 50 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 4e2fd8d..bbf231d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -16,11 +16,20 @@ export interface Platform { export interface DetailedGame extends Game { summary?: string; - developer?: string; - publisher?: string; + developers?: string[]; + publishers?: string[]; genres?: string[]; + franchises?: string[]; // New field releaseDate?: string; screenshots?: string[]; + fsName?: string; + regions?: string[]; + players?: string; + rating?: number; // New field + esrbRating?: string; + ratingIconUrl?: string; // Icon for Age Rating + sha1?: string; + collections?: string[]; } export interface RommCollection { @@ -79,6 +88,14 @@ const formatBytes = (bytes: number) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; +const formatDate = (unixSeconds: any) => { + if (!unixSeconds) return undefined; + const val = Number(unixSeconds); + if (isNaN(val) || val < 1000000) return String(unixSeconds); + const date = new Date(val * 1000); + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date); +}; + export const rommApiClient = { get apiBase() { const cleanUrl = getBaseUrl(); @@ -173,14 +190,127 @@ export const rommApiClient = { if (!res.ok) throw new Error('Failed to fetch game details.'); const json = await res.json(); const game = mapRomToGame(json); + + // Extract metadata with fallbacks across all providers + const providers = [ + json.latest_igdb_metadata, json.igdb_metadata, + json.latest_moby_metadata, json.moby_metadata, + json.latest_screenscraper_metadata, json.screenscraper_metadata, + json.latest_launchbox_metadata, json.launchbox_metadata, + json.latest_steamgrid_metadata, json.steamgrid_metadata + ].filter(Boolean); + + const getFirst = (key: string) => { + for (const p of providers) { + if (p[key] !== undefined && p[key] !== null) return p[key]; + } + return json[key]; + }; + + const getAll = (key: string) => { + const all: any[] = []; + if (Array.isArray(json[key])) all.push(...json[key]); + for (const p of providers) { + if (Array.isArray(p[key])) all.push(...p[key]); + } + return Array.from(new Set(all)).filter(Boolean); + }; + + // Extract Age Ratings from all providers (array format for IGDB/ScreenScraper) + const ageRatings = new Set(); + for (const p of providers) { + if (Array.isArray(p.age_ratings)) { + p.age_ratings.forEach((ar: any) => { + if (ar.rating) ageRatings.add(String(ar.rating)); + }); + } + const pRating = p.esrb_rating || p.esrb || p.pegi_rating || p.pegi; + if (pRating && typeof pRating === 'string') ageRatings.add(pRating); + } + + // Filter out placeholders like "Not Rated" or "Unknown" if we have valid ratings + let finalRatings = Array.from(ageRatings).filter(Boolean); + const placeholders = ['not rated', 'unknown', 'pending', 'nr', 'rp', 'rating pending']; + if (finalRatings.some(r => !placeholders.includes(r.toLowerCase()))) { + finalRatings = finalRatings.filter(r => !placeholders.includes(r.toLowerCase())); + } + + const ageRatingStr = finalRatings.join(' / ') || (json.esrb_rating || json.esrb); + + // Defensive mapping for regions + const regionsRaw = [ + ...(json.rom_regions || []), + ...(json.region ? [json.region] : []), + ...(getFirst('regions') || []) + ]; + + const regions = Array.from(new Set(regionsRaw.map((r: any) => + typeof r === 'string' ? r : (r.display_name || r.name || String(r)) + ))).filter(Boolean); + + const extractCompanies = (type: 'developer' | 'publisher') => { + const companies = new Set(); + + // 1. Root lists + const rootList = type === 'developer' ? (json.developers || json.companies) : (json.publishers || json.companies); + if (Array.isArray(rootList)) { + rootList.forEach(c => companies.add(typeof c === 'string' ? c : (c.name || c.display_name))); + } + + // 2. Provider metadata + for (const p of providers) { + // IGDB involved_companies + if (Array.isArray(p.involved_companies)) { + p.involved_companies.forEach((ic: any) => { + if (ic[type] && ic.company?.name) companies.add(ic.company.name); + }); + } + + // General companies/developers/publishers lists + const pList = p.companies || (type === 'developer' ? (p.developers || p.developer_companies) : (p.publishers || p.publisher_companies)); + if (Array.isArray(pList)) { + pList.forEach((c: any) => companies.add(typeof c === 'string' ? c : (c.name || c.display_name))); + } + } + + // 3. Root singulars + const rootSingular = type === 'developer' ? (json.developer || json.developer_name) : (json.publisher || json.publisher_name); + if (rootSingular && typeof rootSingular === 'string') companies.add(rootSingular); + + return Array.from(companies).filter(Boolean); + }; + + const devList = extractCompanies('developer'); + const pubList = extractCompanies('publisher'); + + // Rating extraction with ScreenScraper/Launchbox fallbacks + let ratingVal = getFirst('rating') || getFirst('total_rating') || getFirst('aggregated_rating') || getFirst('ss_score') || getFirst('community_rating'); + if (ratingVal && String(ratingVal).includes('.')) { + const parsed = parseFloat(String(ratingVal)); + // Normalize 0-10 or 0-5 scales to 100% + if (parsed <= 10) ratingVal = parsed * 10; + else if (parsed <= 5) ratingVal = parsed * 20; + } + return { ...game, - summary: json.summary, - developer: json.developer, - publisher: json.publisher, - genres: json.genres, - releaseDate: json.release_date, - screenshots: (json.screenshots || []).map((s: any) => getFullImageUrl(s.url) || '') + summary: json.summary || getFirst('summary') || getFirst('description'), + developers: devList, + publishers: pubList, + genres: getAll('genres').map(g => typeof g === 'string' ? g : (g.name || g.display_name)), + franchises: getAll('franchises').map(f => typeof f === 'string' ? f : (f.name || f.display_name)), + releaseDate: formatDate(json.release_date || getFirst('first_release_date') || getFirst('release_date')), + screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), + fsName: json.fs_name, + regions: regions.length > 0 ? regions : ['Global'], + players: json.players || getFirst('total_players') || getFirst('players'), + rating: ratingVal ? Math.round(Number(ratingVal)) : undefined, + esrbRating: ageRatingStr, + sha1: json.hashes?.sha1, + collections: Array.from(new Set([ + ...(json.rom_collections || []).map((c: any) => c.name), + ...(getFirst('collections') || []) + ])).filter(Boolean), }; }, diff --git a/src/components/GamesPage.tsx b/src/components/GamesPage.tsx index 92b5582..5914411 100644 --- a/src/components/GamesPage.tsx +++ b/src/components/GamesPage.tsx @@ -12,9 +12,10 @@ interface FocusableItemProps { focusKey?: string; scrollOptions?: ScrollIntoViewOptions; active?: boolean; + onClick?: () => void; } -const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => { +const FocusableItem = ({ onFocus, onEnterPress, onClick, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => { const { mode } = useInputMode(); const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({ onFocus, @@ -22,10 +23,10 @@ const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, s focusKey }); - const focused = mode === 'gamepad' ? rawFocused : false; + const focused = mode !== 'mouse' ? rawFocused : false; useEffect(() => { - if (mode === 'gamepad' && (focused || active) && ref.current) { + if (mode !== 'mouse' && (focused || active) && ref.current) { if (scrollOptions) { ref.current.scrollIntoView(scrollOptions); } else { @@ -35,7 +36,14 @@ const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, s }, [focused, active, mode, scrollOptions]); return ( -
+
{ + if (onClick) onClick(); + }} + > {children(focused)}
); @@ -44,6 +52,7 @@ const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, s const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => ( void }) => ( {}} onEnterPress={() => onLetterPress(letter)} + onClick={() => onLetterPress(letter)} className="w-full flex justify-center py-1" focusKey={`LETTER_${letter}`} scrollOptions={{ behavior: 'smooth', block: 'center' }} @@ -195,13 +206,13 @@ export const GamesPage = () => { }, [filteredPlatforms, selectedPlatformId]); useEffect(() => { - if (gamesQuery.data && gamesQuery.data.length > 0 && !selectedGameId) { - setSelectedGameId(gamesQuery.data[0].id); + if (mode !== 'mouse' && (gamesQuery.data?.length ?? 0) > 0 && !selectedGameId) { + setSelectedGameId(gamesQuery.data?.[0]?.id ?? null); } - }, [gamesQuery.data, selectedGameId]); + }, [gamesQuery.data, selectedGameId, mode]); const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => { - if (mode === 'gamepad') { + if (mode !== 'mouse') { if (!options.skipZoneChange) { setActiveZone('platforms'); } @@ -266,7 +277,7 @@ export const GamesPage = () => { } }); - if (closestKey && mode === 'gamepad') { + if (closestKey && mode !== 'mouse') { console.log("[MagneticFocus] Setting focus to:", closestKey); setFocus(closestKey); } @@ -370,7 +381,7 @@ export const GamesPage = () => { game={game} active={selectedGameId === game.id} onFocus={() => { - if (mode === 'gamepad') { + if (mode !== 'mouse') { setActiveZone('games'); if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); detailTimeoutRef.current = setTimeout(() => { @@ -403,8 +414,8 @@ export const GamesPage = () => {
{detailsQuery.data ? ( -
-
+
+
{/* Poster */}
{ ID: ROM-{detailsQuery.data.id}
-

{detailsQuery.data.title}

+

{detailsQuery.data.title}

-
+ {/* Row 1: Regions, Release Date & Rating */} +
- developer_board - {detailsQuery.data.developer || 'Unknown Dev'} + Region: + + {detailsQuery.data.regions?.join(', ') || 'Global'} +
- calendar_today - {detailsQuery.data.releaseDate || 'N/A'} + Release Date: + + {detailsQuery.data.releaseDate || 'Unknown'} +
+ {detailsQuery.data.rating && ( +
+ Rating: + {detailsQuery.data.rating}% +
+ )} +
+ + {/* Row 2: Companies & Franchises */} +
- sports_esports - {detailsQuery.data.system} + Companies: + + {[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].filter((val, idx, self) => self.indexOf(val) === idx).join(' / ') || 'Unknown Company'} + +
+ {(detailsQuery.data.franchises?.length ?? 0) > 0 && ( +
+ Franchise: + + {detailsQuery.data.franchises?.join(' & ')} + +
+ )} +
+ + {/* Row 3: Genres */} +
+
+ Genres: + + {detailsQuery.data.genres?.join(' • ') || 'No Genres'} +
+ {/* Row 4: Age Rating Text */} + {detailsQuery.data.esrbRating && ( +
+ Age Rating: + + {detailsQuery.data.esrbRating} + +
+ )} +
{ if (mode === 'gamepad') setActiveZone('details'); }} @@ -476,17 +532,6 @@ export const GamesPage = () => { {detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}

- - {detailsQuery.data.genres && detailsQuery.data.genres.length > 0 && ( -
-

Classifications

-
- {detailsQuery.data.genres.map(g => ( - {g} - ))} -
-
- )}
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 69a1d85..dc035ed 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -54,9 +54,9 @@ export const TopNav = () => {
{/* Active Hardware Indicator */} -
+
- {mode === 'gamepad' ? 'sports_esports' : 'mouse'} + {mode === 'gamepad' ? 'sports_esports' : mode === 'keyboard' ? 'keyboard_alt' : 'mouse'}
diff --git a/src/context/InputModeContext.tsx b/src/context/InputModeContext.tsx index 321afa2..2458dc7 100644 --- a/src/context/InputModeContext.tsx +++ b/src/context/InputModeContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; -type InputMode = 'mouse' | 'gamepad'; +type InputMode = 'mouse' | 'gamepad' | 'keyboard'; interface InputModeContextType { mode: InputMode; @@ -14,27 +14,34 @@ export const InputModeProvider: React.FC<{ children: React.ReactNode }> = ({ chi useEffect(() => { // Spatial Engine Override - if (mode === 'mouse') { - document.body.classList.remove('gamepad-active'); - } else { + document.body.classList.remove('gamepad-active', 'keyboard-active'); + if (mode === 'gamepad') { document.body.classList.add('gamepad-active'); + } else if (mode === 'keyboard') { + document.body.classList.add('keyboard-active'); } }, [mode]); useEffect(() => { - const onMouseMove = () => { - setMode('mouse'); - }; - const onMouseDown = () => { - setMode('mouse'); + const onMouseMove = () => setMode('mouse'); + const onMouseDown = () => setMode('mouse'); + + const onKeyDown = (e: KeyboardEvent) => { + // Common navigation keys + const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab', ' ', 'Backspace']; + if (navKeys.includes(e.key)) { + setMode('keyboard'); + } }; window.addEventListener('mousemove', onMouseMove, { passive: true }); window.addEventListener('mousedown', onMouseDown, { passive: true }); + window.addEventListener('keydown', onKeyDown, { passive: true }); return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('keydown', onKeyDown); }; }, []);