import React, { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation'; import { CollectionModal } from './CollectionModal'; import { rommApiClient, Platform, Game } from '../api/client'; import { useInputMode } from '../context/InputModeContext'; interface FocusableItemProps { onFocus: () => void; onEnterPress?: () => void; children: (focused: boolean) => React.ReactNode; className?: string; focusKey?: string; scrollOptions?: ScrollIntoViewOptions; active?: boolean; onClick?: () => void; } const FocusableItem = ({ onFocus, onEnterPress, onClick, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => { const { mode } = useInputMode(); const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({ onFocus, onEnterPress, focusKey }); const focused = mode !== 'mouse' ? rawFocused : false; useEffect(() => { if (mode !== 'mouse' && (focused || active) && ref.current) { if (scrollOptions) { ref.current.scrollIntoView(scrollOptions); } else { ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } } }, [focused, active, mode, scrollOptions]); return (
{ if (onClick) onClick(); }} > {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}
)}
); const AlphabetScroller = ({ letters, onLetterPress }: { letters: string[], onLetterPress: (letter: string) => void }) => { return (
{letters.map(letter => ( {}} onEnterPress={() => onLetterPress(letter)} onClick={() => onLetterPress(letter)} className="w-full flex justify-center py-1" focusKey={`LETTER_${letter}`} scrollOptions={{ behavior: 'smooth', block: 'center' }} > {(focused) => (
{letter}
)}
))}
); }; export const GamesPage = () => { const { mode } = useInputMode(); const [selectedPlatformId, setSelectedPlatformId] = useState(null); const [selectedGameId, setSelectedGameId] = useState(null); const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false); const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null); const detailTimeoutRef = React.useRef(null); const { ref: platformsRef, focusKey: platformsFocusKey } = useFocusable({ focusKey: 'PLATFORMS_ZONE', trackChildren: true, preferredChildFocusKey: selectedPlatformId ? `PLATFORM_${selectedPlatformId}` : undefined, onBlur: () => setActiveZone(prev => prev === 'platforms' ? null : prev) }); const { ref: gamesRef, focusKey: gamesFocusKey } = useFocusable({ focusKey: 'GAMES_ZONE', trackChildren: true, preferredChildFocusKey: selectedGameId ? `GAME_${selectedGameId}` : undefined, onBlur: () => setActiveZone(prev => prev === 'games' ? null : prev) }); const { ref: alphabetRef, focusKey: alphabetFocusKey } = useFocusable({ focusKey: 'ALPHABET_ZONE', trackChildren: true }); const { ref: detailsRef, focusKey: detailsFocusKey } = useFocusable({ focusKey: 'DETAILS_ZONE', trackChildren: true }); // 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 queryClient = useQueryClient(); const favoriteMutation = useMutation({ mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) => rommApiClient.toggleFavorite(gameId, favorite), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['gameDetails'] }); } }); const collectionsQuery = useQuery({ queryKey: ['collections'], queryFn: () => rommApiClient.fetchCollections() }); const addToCollectionMutation = useMutation({ mutationFn: async ({ collectionId, gameId, name }: { collectionId: string, gameId: string, name: string }) => { const details = await rommApiClient.fetchCollectionDetails(collectionId); const ids: number[] = details.rom_ids || []; const numId = parseInt(gameId); if (!ids.includes(numId)) { ids.push(numId); return rommApiClient.updateCollection(collectionId, { name: name, rom_ids: ids }); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['gameDetails'] }); setIsCollectionModalOpen(false); } }); const createCollectionMutation = useMutation({ mutationFn: async ({ name, gameId }: { name: string, gameId: string }) => { const newCol = await rommApiClient.createCollection(name); return rommApiClient.updateCollection(newCol.id, { name: newCol.name, rom_ids: [parseInt(gameId)] }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['collections'] }); queryClient.invalidateQueries({ queryKey: ['gameDetails'] }); setIsCollectionModalOpen(false); } }); 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 (mode !== 'mouse' && (gamesQuery.data?.length ?? 0) > 0 && !selectedGameId) { setSelectedGameId(gamesQuery.data?.[0]?.id ?? null); } }, [gamesQuery.data, selectedGameId, mode]); const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => { if (mode !== 'mouse') { if (!options.skipZoneChange) { setActiveZone('platforms'); } if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); detailTimeoutRef.current = setTimeout(() => { setSelectedPlatformId(id); setSelectedGameId(null); }, 150); } else { setSelectedPlatformId(id); setSelectedGameId(null); } }; // 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}`); } }; const updateFocusFromScroll = (container: HTMLElement) => { const rect = container.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const candidates = Array.from(container.querySelectorAll('[data-focusable-key]')); if (candidates.length === 0) return; let closestKey: string | null = null; let minDistance = Infinity; candidates.forEach((el) => { const elRect = el.getBoundingClientRect(); const elCenterX = elRect.left + elRect.width / 2; const elCenterY = elRect.top + elRect.height / 2; const dist = Math.sqrt(Math.pow(centerX - elCenterX, 2) + Math.pow(centerY - elCenterY, 2)); if (dist < minDistance) { minDistance = dist; closestKey = el.getAttribute('data-focusable-key'); } }); if (closestKey && mode !== 'mouse' && !isCollectionModalOpen) { console.log("[MagneticFocus] Setting focus to:", closestKey); setFocus(closestKey); } }; // Right analog stick scrolling — scrolls whichever zone is currently active const SCROLL_SPEED = 12; // Increased speed for right stick useEffect(() => { const onAnalogScroll = (e: Event) => { const { dx, dy } = (e as CustomEvent<{ dx: number; dy: number }>).detail; let target: HTMLElement | null = null; if (activeZone === 'platforms') target = platformsRef.current as HTMLElement | null; else if (activeZone === 'games') target = gamesRef.current as HTMLElement | null; else if (activeZone === 'alphabet') target = alphabetRef.current as HTMLElement | null; else if (activeZone === 'details') target = detailsRef.current as HTMLElement | null; if (target) { target.scrollLeft += dx * SCROLL_SPEED; target.scrollTop += dy * SCROLL_SPEED; // Magnetic Focus: Update the focused element to match the new scroll position if (activeZone === 'platforms' || activeZone === 'games' || activeZone === 'alphabet') { updateFocusFromScroll(target); } } }; window.addEventListener('analogscroll', onAnalogScroll); return () => window.removeEventListener('analogscroll', onAnalogScroll); }, [activeZone, platformsRef, gamesRef, alphabetRef, detailsRef, mode]); // Bumper navigation — LB/RB to cycle platforms globally useEffect(() => { const onCycle = (direction: 'next' | 'prev') => { if (filteredPlatforms.length <= 1) return; // Find current platform index (use most recent selectedPlatformId) const currentIndex = filteredPlatforms.findIndex(p => p.id === selectedPlatformId); if (currentIndex === -1 && selectedPlatformId !== null) return; let nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; // Looping if (nextIndex >= filteredPlatforms.length) nextIndex = 0; if (nextIndex < 0) nextIndex = filteredPlatforms.length - 1; const nextPlatform = filteredPlatforms[nextIndex]; // Silent switch: don't change activeZone highlight unless we are already there handlePlatformFocus(nextPlatform.id, { skipZoneChange: activeZone !== 'platforms' }); // If we are currently in the platforms zone, move focus visually to the new item if (activeZone === 'platforms') { setFocus(`PLATFORM_${nextPlatform.id}`); } }; const handlePrev = () => onCycle('prev'); const handleNext = () => onCycle('next'); window.addEventListener('previousplatform', handlePrev); window.addEventListener('nextplatform', handleNext); return () => { window.removeEventListener('previousplatform', handlePrev); window.removeEventListener('nextplatform', handleNext); }; }, [filteredPlatforms, selectedPlatformId, activeZone, mode]); if (platformsQuery.isLoading) return
Initializing Archives...
; return (
{/* Header / Platforms */}
{filteredPlatforms.map(p => ( handlePlatformFocus(p.id, { skipZoneChange: false })} /> ))}
{/* Main Split Layout */}
{/* Left Column: Game List + Alphabet */}
{gamesQuery.isLoading ? (
Retrieving index...
) : ( gamesQuery.data?.map(game => ( { if (mode !== 'mouse') { setActiveZone('games'); if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); detailTimeoutRef.current = setTimeout(() => { setSelectedGameId(game.id); }, 150); } else { setSelectedGameId(game.id); } }} /> )) )}
{activeLetters.length > 0 && (
)}
{/* Right Column: Game Details */}
{detailsQuery.data ? (
{/* Poster */}
{/* Info Pane */}
PLAYABLE ID: ROM-{detailsQuery.data.id}

{detailsQuery.data.title}

{/* Metadata rows with consistent spacing (mb-2) */}
Region: {detailsQuery.data.regions?.join(', ') || 'Global'}
Release Date: {detailsQuery.data.releaseDate || 'Unknown'}
{detailsQuery.data.rating && (
Rating: {detailsQuery.data.rating}%
)}
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(' & ')}
)}
Genres: {detailsQuery.data.genres?.join(' • ') || 'No Genres'}
{detailsQuery.data.esrbRating && (
Age Rating: {detailsQuery.data.esrbRating}
)} {/* Summary Section - Fixed Height & Scrollable */}

{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."}

{/* Action Buttons - Pushed to bottom, aligned with poster bottom */}
{ if (mode === 'gamepad') setActiveZone('details'); }} onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)} onClick={() => console.log("Play Game", detailsQuery.data?.id)} className="shrink-0" focusKey="DETAILS_PLAY" > {(focused) => ( )} { if (mode === 'gamepad') setActiveZone('details'); }} onEnterPress={() => console.log("Download", detailsQuery.data?.id)} onClick={() => console.log("Download", detailsQuery.data?.id)} className="shrink-0" focusKey="DETAILS_DOWNLOAD" > {(focused) => ( )}
{ if (mode === 'gamepad') setActiveZone('details'); }} onEnterPress={() => { if (detailsQuery.data) { favoriteMutation.mutate({ gameId: detailsQuery.data.id, favorite: !detailsQuery.data.favorite }); } }} onClick={() => { if (detailsQuery.data) { favoriteMutation.mutate({ gameId: detailsQuery.data.id, favorite: !detailsQuery.data.favorite }); } }} className="shrink-0" focusKey="DETAILS_FAVORITE" > {(focused) => ( )} { if (detailsQuery.data) setIsCollectionModalOpen(true); }} onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }} onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }} className="shrink-0" focusKey="DETAILS_COLLECTION" > {(focused) => ( )} {detailsQuery.data?.manualUrl && ( { if (mode === 'gamepad') setActiveZone('details'); }} onEnterPress={() => window.open(detailsQuery.data?.manualUrl, '_blank')} onClick={() => window.open(detailsQuery.data?.manualUrl, '_blank')} className="shrink-0" focusKey="DETAILS_MANUAL" > {(focused) => ( )} )}
) : selectedGameId ? (
progress_activity
Hydrating Metadata...
) : (
videogame_asset

Target selection required

)}
{/* Collection Management Modal Overlay */} {isCollectionModalOpen && detailsQuery.data && ( setIsCollectionModalOpen(false)} onSelect={(col) => addToCollectionMutation.mutate({ collectionId: col.id, gameId: detailsQuery.data!.id, name: col.name })} onCreate={(name) => createCollectionMutation.mutate({ name, gameId: detailsQuery.data!.id })} /> )}
); };