From 5fe789a8fd64d073228f0d8516086e1b293dff8a Mon Sep 17 00:00:00 2001 From: roormonger <34205054+roormonger@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:24:45 -0400 Subject: [PATCH] basic games page layout --- src/App.tsx | 3 +- src/api/client.ts | 77 ++++++-- src/components/GamesPage.tsx | 360 +++++++++++++++++++++++++++++++++++ src/index.css | 25 +++ 4 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 src/components/GamesPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8a79dfc..2d84917 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Sidebar } from './components/Sidebar'; import { TopNav } from './components/TopNav'; import LibraryGrid from './components/LibraryGrid'; import { Login } from './components/Login'; +import { GamesPage } from './components/GamesPage'; import { Settings } from './components/Settings'; import { AuthProvider, useAuth } from './context/AuthContext'; import { useGamepad } from './hooks/useGamepad'; @@ -43,7 +44,7 @@ function App() { } > } /> - } /> + } /> } /> diff --git a/src/api/client.ts b/src/api/client.ts index b8c42af..4e2fd8d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,8 +2,25 @@ export interface Game { id: string; title: string; system: string; + size: string; coverUrl: string; - size: number; +} + +export interface Platform { + id: number; + name: string; + slug: string; + romCount: number; + iconUrl?: string; +} + +export interface DetailedGame extends Game { + summary?: string; + developer?: string; + publisher?: string; + genres?: string[]; + releaseDate?: string; + screenshots?: string[]; } export interface RommCollection { @@ -45,23 +62,23 @@ const getFullImageUrl = (urlPath: string | undefined): string | undefined => { // Map RomM's native spec to our simplified app interface const mapRomToGame = (apiRom: any): Game => { - let coverUrl = 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400'; - - if (apiRom.url_cover) { - coverUrl = getFullImageUrl(apiRom.url_cover) || coverUrl; - } else if (apiRom.url_covers_large && apiRom.url_covers_large.length > 0) { - coverUrl = getFullImageUrl(apiRom.url_covers_large[0]) || coverUrl; - } - return { id: String(apiRom.id), - title: apiRom.name || apiRom.fs_name_no_ext || 'Unknown Title', + title: apiRom.name || apiRom.fs_name, system: apiRom.platform_display_name || apiRom.platform_slug || 'Unknown Platform', - coverUrl, - size: apiRom.fs_size_bytes || 0 + size: formatBytes(apiRom.fs_size_bytes || 0), + coverUrl: getFullImageUrl(apiRom.url_cover) || 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400', }; }; +const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +}; + export const rommApiClient = { get apiBase() { const cleanUrl = getBaseUrl(); @@ -131,6 +148,42 @@ export const rommApiClient = { return res.json(); }, + async fetchPlatforms(): Promise { + const res = await fetch(`${this.apiBase}/platforms`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch platforms.'); + const json = await res.json(); + return json.map((p: any) => ({ + id: p.id, + name: p.display_name || p.name || p.fs_slug, + slug: p.fs_slug, + romCount: p.rom_count || 0, + iconUrl: getFullImageUrl(p.url_logo) + })); + }, + + async fetchGamesByPlatform(platformId: number): Promise { + const res = await fetch(`${this.apiBase}/roms?platform_ids=${platformId}&limit=100`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch platform games.'); + const json = await res.json(); + return json.items ? json.items.map(mapRomToGame) : []; + }, + + async fetchGameDetails(gameId: string): Promise { + const res = await fetch(`${this.apiBase}/roms/${gameId}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch game details.'); + const json = await res.json(); + const game = mapRomToGame(json); + 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) || '') + }; + }, + async fetchCollections(): Promise { const res = await fetch(`${this.apiBase}/collections`, { headers: this.headers }); if (!res.ok) throw new Error('Failed to fetch collections meta.'); diff --git a/src/components/GamesPage.tsx b/src/components/GamesPage.tsx new file mode 100644 index 0000000..377284e --- /dev/null +++ b/src/components/GamesPage.tsx @@ -0,0 +1,360 @@ +import React, { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation'; +import { rommApiClient, Platform, Game } from '../api/client'; + +interface FocusableItemProps { + onFocus: () => void; + onEnterPress?: () => void; + children: (focused: boolean) => React.ReactNode; + className?: string; + focusKey?: string; + scrollOptions?: ScrollIntoViewOptions; +} + +const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions }: FocusableItemProps) => { + const { ref, focused } = useFocusable({ + onFocus: () => { + onFocus(); + if (scrollOptions) { + ref.current?.scrollIntoView(scrollOptions); + } else { + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + }, + onEnterPress, + focusKey + }); + + return ( +
+ {children(focused)} +
+ ); +}; + +const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => ( + + {(focused) => ( +
+
+ {platform.iconUrl ? ( + {platform.name} + ) : ( + sports_esports + )} +
+
+ {platform.name} +
+
+ )} +
+); + +const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, onFocus: () => void }) => ( + + {(focused) => ( +
+
+ +
+
+
+ {game.title} +
+
+ {game.size} +
+
+ {focused &&
chevron_right
} +
+ )} +
+); + +const AlphabetScroller = ({ + letters, + onLetterPress +}: { + letters: string[], + onLetterPress: (letter: string) => void +}) => { + const { focusKey } = useFocusable({ + focusKey: 'ALPHABET_SCROLLER' + }); + + return ( + +
+ {letters.map(letter => ( + {}} + onEnterPress={() => onLetterPress(letter)} + className="w-full flex justify-center py-1" + scrollOptions={{ behavior: 'smooth', block: 'center' }} + > + {(focused) => ( +
+ {letter} +
+ )} +
+ ))} +
+
+ ); +}; + +export const GamesPage = () => { + const { ref: pageRef, focusKey: pageFocusKey } = useFocusable(); + const [selectedPlatformId, setSelectedPlatformId] = useState(null); + const [selectedGameId, setSelectedGameId] = useState(null); + + // Queries + const platformsQuery = useQuery({ + queryKey: ['platforms'], + queryFn: () => rommApiClient.fetchPlatforms() + }); + + const gamesQuery = useQuery({ + queryKey: ['games', selectedPlatformId], + queryFn: () => selectedPlatformId ? rommApiClient.fetchGamesByPlatform(selectedPlatformId) : Promise.resolve([]), + enabled: !!selectedPlatformId + }); + + const detailsQuery = useQuery({ + queryKey: ['gameDetails', selectedGameId], + queryFn: () => selectedGameId ? rommApiClient.fetchGameDetails(selectedGameId) : Promise.resolve(null), + enabled: !!selectedGameId + }); + + const filteredPlatforms = React.useMemo(() => + platformsQuery.data?.filter(p => p.romCount > 0) || [], + [platformsQuery.data] + ); + + // Auto-select first items + useEffect(() => { + if (filteredPlatforms.length > 0 && selectedPlatformId === null) { + setSelectedPlatformId(filteredPlatforms[0].id); + } + }, [filteredPlatforms, selectedPlatformId]); + + useEffect(() => { + if (gamesQuery.data && gamesQuery.data.length > 0 && !selectedGameId) { + setSelectedGameId(gamesQuery.data[0].id); + } + }, [gamesQuery.data, selectedGameId]); + + const handlePlatformFocus = (id: number) => { + setSelectedPlatformId(id); + setSelectedGameId(null); // Reset game selection when platform changes + }; + + // Alphabet Logic + const activeLetters = React.useMemo(() => { + if (!gamesQuery.data) return []; + const letters = new Set(); + gamesQuery.data.forEach(game => { + const firstChar = game.title.charAt(0).toUpperCase(); + if (/[A-Z]/.test(firstChar)) { + letters.add(firstChar); + } else { + letters.add('#'); + } + }); + return Array.from(letters).sort(); + }, [gamesQuery.data]); + + const handleLetterPress = (letter: string) => { + if (!gamesQuery.data) return; + const game = gamesQuery.data.find(g => { + const firstChar = g.title.charAt(0).toUpperCase(); + return letter === '#' ? !/[A-Z]/.test(firstChar) : firstChar === letter; + }); + if (game) { + setSelectedGameId(game.id); + setFocus(`GAME_${game.id}`); + } + }; + + if (platformsQuery.isLoading) return
Initializing Archives...
; + + return ( + +
+ {/* Header / Platforms */} +
+
+ {filteredPlatforms.map(p => ( + handlePlatformFocus(p.id)} + /> + ))} +
+
+ + {/* Main Split Layout */} +
+ {/* Left Column: Game List + Alphabet */} +
+
+ {gamesQuery.isLoading ? ( +
Retrieving index...
+ ) : ( + gamesQuery.data?.map(game => ( + setSelectedGameId(game.id)} + /> + )) + )} +
+ + {activeLetters.length > 0 && ( + + )} +
+ + {/* Right Column: Game Details */} +
+ {detailsQuery.data ? ( +
+
+ {/* Poster */} +
+ +
+
+ + {/* Info Pane */} +
+
+ PLAYABLE + ID: ROM-{detailsQuery.data.id} +
+ +

{detailsQuery.data.title}

+ +
+
+ developer_board + {detailsQuery.data.developer || 'Unknown Dev'} +
+
+ calendar_today + {detailsQuery.data.releaseDate || 'N/A'} +
+
+ sports_esports + {detailsQuery.data.system} +
+
+ +
+ {}} + onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)} + className="shrink-0" + > + {(focused) => ( + + )} + + + {}} + onEnterPress={() => console.log("Download", detailsQuery.data?.id)} + className="shrink-0" + > + {(focused) => ( + + )} + +
+ +
+
+

Brief Summary

+

+ {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} + ))} +
+
+ )} +
+
+
+
+ ) : selectedGameId ? ( +
+
+
progress_activity
+
Hydrating Metadata...
+
+
+ ) : ( +
+
+ videogame_asset +

Target selection required

+
+
+ )} +
+
+
+
+ ); +}; diff --git a/src/index.css b/src/index.css index 70eaa1a..b94f90d 100644 --- a/src/index.css +++ b/src/index.css @@ -6,6 +6,31 @@ :root { color-scheme: dark; } + + /* Premium Global Scrollbars */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 20px; + border: 1px solid transparent; + background-clip: content-box; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); + background-clip: content-box; + } + + /* Firefox Support */ + * { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; + } } body {