diff --git a/src/App.tsx b/src/App.tsx index a3f739e..58e0880 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { TopNav } from './components/TopNav'; import LibraryGrid from './components/LibraryGrid'; import { Login } from './components/Login'; import { GamesPage } from './components/GamesPage'; +import { CollectionsPage } from './components/CollectionsPage'; import { Settings } from './components/Settings'; import { AuthProvider, useAuth } from './context/AuthContext'; import { useGamepad } from './hooks/useGamepad'; @@ -44,7 +45,16 @@ function App() { } > } /> - } /> + }> + }> + } /> + + + }> + }> + } /> + + } /> diff --git a/src/components/AchievementList.tsx b/src/components/AchievementList.tsx new file mode 100644 index 0000000..70f60a7 --- /dev/null +++ b/src/components/AchievementList.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { RAAchievement, RAProgression } from '../api/client'; + +export const AchievementList = ({ raId, achievements, userProgression }: { + raId?: number, + achievements?: RAAchievement[], + userProgression?: RAProgression | null +}) => { + if (!raId || !achievements || achievements.length === 0) { + return ( +
+ trophy +
Achievements Unavailable
+
+ ); + } + + const gameProgress = userProgression?.results.find(r => r.rom_ra_id === raId); + const earnedIds = new Set(gameProgress?.earned_achievements.map(a => String(a.id)) || []); + + const earnedCount = gameProgress?.num_awarded || 0; + const totalCount = achievements.length; + const progressPercent = totalCount > 0 ? (earnedCount / totalCount) * 100 : 0; + + return ( +
+ {/* Header */} +
+
+ workspace_premium +

RetroAchievements

+
+
+ {earnedCount} / {totalCount} +
+
+ + {/* Progress Bar */} +
+
+
+ + {/* Achievement List */} +
+ {achievements.map((achievement) => { + const isEarned = earnedIds.has(String(achievement.ra_id)); + const badgeId = achievement.badge_id || '00000'; + return ( +
+ {/* Badge */} +
+ +
+ + {/* Text */} +
+
+

+ {achievement.title} +

+ + {achievement.points} pts + +
+

+ {achievement.description} +

+
+ + {/* Status Icon */} + {isEarned && ( + verified + )} +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/AlphabetScroller.tsx b/src/components/AlphabetScroller.tsx new file mode 100644 index 0000000..833d7aa --- /dev/null +++ b/src/components/AlphabetScroller.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { FocusableItem } from './FocusableItem'; + +export 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} +
+ )} +
+ ))} +
+ ); +}; diff --git a/src/components/AtelierExplorer.tsx b/src/components/AtelierExplorer.tsx new file mode 100644 index 0000000..1c3938e --- /dev/null +++ b/src/components/AtelierExplorer.tsx @@ -0,0 +1,263 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation'; +import { useInputMode } from '../context/InputModeContext'; + +interface Category { + id: string | number; + name: string; +} + +interface AtelierExplorerProps { + categoryName: string; // e.g., "PLATFORM" or "COLLECTION" + categoryRoute: string; // e.g., "games" or "collections" + categories: C[]; + items: I[]; + isLoadingCategories: boolean; + isLoadingItems: boolean; + + renderCategory: (category: C, active: boolean, onSelect: () => void) => React.ReactNode; + renderListItem: (item: I, active: boolean, onFocus: () => void) => React.ReactNode; + renderDetails: (itemId: string, activeZone: string, setActiveZone: (z: string) => void) => React.ReactNode; + + // Modals / Overlays + overlays?: React.ReactNode; + + // Optional alphabet scroller + alphabet?: React.ReactNode; + + // Event overrides + onBumperCycle?: (direction: 'next' | 'prev') => void; +} + +export const AtelierExplorer = ({ + categoryName, + categoryRoute, + categories, + items, + isLoadingCategories, + isLoadingItems, + renderCategory, + renderListItem, + renderDetails, + overlays, + alphabet, + onBumperCycle +}: AtelierExplorerProps) => { + const { mode } = useInputMode(); + const { platformId, collectionId, romId } = useParams(); + const navigate = useNavigate(); + + // Use either platformId or collectionId based on route + const activeCategoryId = platformId || collectionId; + + const [activeZone, setActiveZone] = useState('games'); + const detailTimeoutRef = useRef | null>(null); + + // Refs for scrolling + const categoriesRef = useRef(null); + const itemsRef = useRef(null); + const alphabetRef = useRef(null); + const detailsRef = useRef(null); + + // Spatial Nav Keys + const { ref: categoriesRefNav, focusKey: categoriesFocusKey } = useFocusable(); + const { ref: itemsRefNav, focusKey: itemsFocusKey } = useFocusable(); + const { ref: alphabetRefNav, focusKey: alphabetFocusKey } = useFocusable(); + const { ref: detailsRefNav, focusKey: detailsFocusKey } = useFocusable(); + + // URL -> State Sync (Focus Only) + useEffect(() => { + if (mode !== 'mouse') { + if (romId) { + setFocus(`ITEM_${romId}`); + } else if (activeCategoryId) { + setFocus(`${categoryName}_${activeCategoryId}`); + } + } + }, [activeCategoryId, romId, mode, categoryName]); + + // Initial Auto-select + useEffect(() => { + if (categories.length > 0 && !activeCategoryId) { + navigate(`/${categoryRoute}/${categories[0].id}`, { replace: true }); + } + }, [categories, activeCategoryId, navigate, categoryRoute]); + + useEffect(() => { + if (mode !== 'mouse' && items.length > 0 && !romId && activeCategoryId) { + navigate(`/${categoryRoute}/${activeCategoryId}/${items[0].id}`, { replace: true }); + } + }, [items, romId, activeCategoryId, mode, navigate, categoryRoute]); + + const handleCategoryFocus = (id: string | number, options: { skipZoneChange?: boolean } = {}) => { + if (mode !== 'mouse') { + if (!options.skipZoneChange) setActiveZone('categories'); + if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); + detailTimeoutRef.current = setTimeout(() => { + navigate(`/${categoryRoute}/${id}`, { replace: true }); + }, 150); + } else { + navigate(`/${categoryRoute}/${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') { + setFocus(closestKey); + } + }; + + // Right analog stick scrolling + const SCROLL_SPEED = 12; + useEffect(() => { + const onAnalogScroll = (e: Event) => { + const { dx, dy } = (e as CustomEvent<{ dx: number; dy: number }>).detail; + let target: HTMLElement | null = null; + if (activeZone === 'categories') target = categoriesRef.current; + else if (activeZone === 'games') target = itemsRef.current; + else if (activeZone === 'alphabet') target = alphabetRef.current; + else if (activeZone === 'details') target = detailsRef.current; + + if (target) { + target.scrollLeft += dx * SCROLL_SPEED; + target.scrollTop += dy * SCROLL_SPEED; + + if (activeZone !== 'details') { + updateFocusFromScroll(target); + } + } + }; + window.addEventListener('analogscroll', onAnalogScroll); + return () => window.removeEventListener('analogscroll', onAnalogScroll); + }, [activeZone]); + + // Bumper Navigation + useEffect(() => { + const onCycle = (direction: 'next' | 'prev') => { + if (onBumperCycle) { + onBumperCycle(direction); + } else { + if (categories.length <= 1) return; + const currentIndex = categories.findIndex(c => String(c.id) === String(activeCategoryId)); + if (currentIndex === -1 && activeCategoryId) return; + + let nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; + if (nextIndex >= categories.length) nextIndex = 0; + if (nextIndex < 0) nextIndex = categories.length - 1; + + const nextCat = categories[nextIndex]; + handleCategoryFocus(nextCat.id, { skipZoneChange: activeZone !== 'categories' }); + if (activeZone === 'categories') { + setFocus(`${categoryName}_${nextCat.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); + }; + }, [categories, activeCategoryId, activeZone, mode, onBumperCycle]); + + if (isLoadingCategories) return
Initializing Archives...
; + + return ( +
+ {/* Header / Categories */} +
+ +
+
+ {categories.map(c => renderCategory(c, String(activeCategoryId) === String(c.id), () => handleCategoryFocus(c.id, { skipZoneChange: false })))} +
+
+
+
+ + {/* Main Split Layout */} +
+ {/* Left Column: List + Alphabet */} +
+
+ +
+
+ {isLoadingItems ? ( +
Retrieving index...
+ ) : ( + items.map(item => renderListItem(item, romId === item.id, () => { + if (mode !== 'mouse') { + setActiveZone('games'); + if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); + detailTimeoutRef.current = setTimeout(() => { + navigate(`/${categoryRoute}/${activeCategoryId}/${item.id}`, { replace: true }); + }, 150); + } else { + navigate(`/${categoryRoute}/${activeCategoryId}/${item.id}`); + } + })) + )} +
+
+
+
+ + {alphabet && ( + +
+
+ {alphabet} +
+
+
+ )} +
+ + {/* Right Column: Details */} + +
+
+ {romId ? renderDetails(romId, activeZone, setActiveZone) : ( +
+
+ videogame_asset +

Target selection required

+
+
+ )} +
+
+
+
+ + {overlays} +
+ ); +}; diff --git a/src/components/CollectionHeaderItem.tsx b/src/components/CollectionHeaderItem.tsx new file mode 100644 index 0000000..0b70650 --- /dev/null +++ b/src/components/CollectionHeaderItem.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { RommCollection } from '../api/client'; +import { FocusableItem } from './FocusableItem'; + +export const CollectionHeaderItem = ({ collection, active, onSelect }: { collection: RommCollection, active: boolean, onSelect: () => void }) => ( + + {(focused) => ( +
+
+ {collection.coverUrl ? ( + {collection.name} + ) : ( + + {collection.is_favorite ? 'star' : 'library_books'} + + )} +
+
+ {collection.name} +
+
+ )} +
+); diff --git a/src/components/CollectionsPage.tsx b/src/components/CollectionsPage.tsx new file mode 100644 index 0000000..e120484 --- /dev/null +++ b/src/components/CollectionsPage.tsx @@ -0,0 +1,122 @@ +import { useState, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { GameListItem } from './GameListItem'; +import { GameDetailsPane } from './GameDetailsPane'; +import { AlphabetScroller } from './AlphabetScroller'; +import { AtelierExplorer } from './AtelierExplorer'; +import { CollectionHeaderItem } from './CollectionHeaderItem'; +import { CollectionModal } from './CollectionModal'; +import { ManualModal } from './ManualModal'; +import { EmulatorOverlay } from './EmulatorOverlay'; +import { rommApiClient } from '../api/client'; +import { useInputMode } from '../context/InputModeContext'; + +export const CollectionsPage = () => { + const { mode } = useInputMode(); + const { collectionId, romId } = useParams(); + const queryClient = useQueryClient(); + + const [activeMediaIndex, setActiveMediaIndex] = useState(0); + const [playingGameId, setPlayingGameId] = useState(null); + const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false); + const [isManualModalOpen, setIsManualModalOpen] = useState(false); + + const collectionsQuery = useQuery({ + queryKey: ['collections'], + queryFn: () => rommApiClient.fetchCollections() + }); + + const activeCollectionId = collectionId || ''; + const activeCollection = collectionsQuery.data?.find(c => String(c.id) === String(activeCollectionId)); + + const detailsQuery = useQuery({ + queryKey: ['gameDetails', romId], + queryFn: () => rommApiClient.fetchGameDetails(romId!), + enabled: !!romId, + staleTime: 1000 * 60 * 5, + }); + + const userQuery = useQuery({ + queryKey: ['currentUser'], + queryFn: () => rommApiClient.fetchCurrentUser() + }); + + const favoriteMutation = useMutation({ + mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) => + rommApiClient.toggleFavorite(gameId, favorite), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gameDetails', romId] }); + queryClient.invalidateQueries({ queryKey: ['collections'] }); + } + }); + + const activeLetters = useMemo(() => { + if (!activeCollection?.games) return []; + const letters = new Set(); + activeCollection.games.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(); + }, [activeCollection]); + + return ( + ({ id: c.id, name: c.name }))} + items={activeCollection?.games || []} + isLoadingCategories={collectionsQuery.isLoading} + isLoadingItems={collectionsQuery.isLoading} + renderCategory={(c, active, onSelect) => { + const fullCol = collectionsQuery.data?.find(col => String(col.id) === String(c.id)); + if (!fullCol) return null; + return ; + }} + renderListItem={(game, active, onFocus) => ( + + )} + alphabet={activeLetters.length > 0 && ( + {}} /> + )} + renderDetails={(itemId, activeZone, setActiveZone) => ( + detailsQuery.data ? ( + favoriteMutation.mutate({ gameId: id, favorite: fav })} + onOpenCollection={() => setIsCollectionModalOpen(true)} + onOpenManual={() => setIsManualModalOpen(true)} + mode={mode} + /> + ) : ( +
progress_activity
Hydrating Metadata...
+ ) + )} + overlays={ + <> + {isCollectionModalOpen && detailsQuery.data && ( + setIsCollectionModalOpen(false)} + onSelect={() => {}} + onCreate={() => {}} + /> + )} + {playingGameId && setPlayingGameId(null)} />} + {isManualModalOpen && detailsQuery.data && ( + setIsManualModalOpen(false)} /> + )} + + } + /> + ); +}; diff --git a/src/components/FocusableItem.tsx b/src/components/FocusableItem.tsx new file mode 100644 index 0000000..5148d56 --- /dev/null +++ b/src/components/FocusableItem.tsx @@ -0,0 +1,48 @@ +import React, { useEffect } from 'react'; +import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useInputMode } from '../context/InputModeContext'; + +export interface FocusableItemProps { + onFocus: () => void; + onEnterPress?: () => void; + children: (focused: boolean) => React.ReactNode; + className?: string; + focusKey?: string; + scrollOptions?: ScrollIntoViewOptions; + active?: boolean; + onClick?: () => void; +} + +export 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)} +
+ ); +}; diff --git a/src/components/GameDetailsPane.tsx b/src/components/GameDetailsPane.tsx new file mode 100644 index 0000000..dc9b844 --- /dev/null +++ b/src/components/GameDetailsPane.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { DetailedGame, RAProgression, UserProfile } from '../api/client'; +import { FocusableItem } from './FocusableItem'; +import { AchievementList } from './AchievementList'; + +interface GameDetailsPaneProps { + game: DetailedGame; + itemId: string; + user?: UserProfile | null; + activeMediaIndex: number; + setActiveMediaIndex: (idx: number) => void; + activeZone: string; + setActiveZone: (zone: string) => void; + onPlay: (id: string) => void; + onFavorite: (id: string, fav: boolean) => void; + onOpenCollection: () => void; + onOpenManual: () => void; + mode: string; +} + +export const GameDetailsPane = ({ + game, + itemId, + user, + activeMediaIndex, + setActiveMediaIndex, + activeZone, + setActiveZone, + onPlay, + onFavorite, + onOpenCollection, + onOpenManual, + mode +}: GameDetailsPaneProps) => { + const mediaItems: { type: 'video' | 'image', url: string, youtubeId?: string }[] = []; + if (game.youtubeId) mediaItems.push({ type: 'video', url: '', youtubeId: game.youtubeId }); + else if (game.videoUrl) mediaItems.push({ type: 'video', url: game.videoUrl }); + if (game.screenshots) { + game.screenshots.forEach(s => mediaItems.push({ type: 'image', url: s })); + } + + const setFocusZone = () => { if (mode === 'gamepad') setActiveZone('details'); }; + + return ( +
+
+ {/* Poster */} +
+ +
+
+ + {/* Metadata Content */} +
+
+
Playable
+
{game.players || '1 Player'}
+
+

{game.title}

+
+ +
+
+
Region: {game.regions?.join(', ') || 'N/A'}
+
Release date: {game.releaseDate || 'N/A'}
+
+
+
Franchise: {game.collections?.join(', ') || 'N/A'}
+
Companies: {[...(game.developers || []), ...(game.publishers || [])].slice(0, 3).join(', ') || 'Unknown Company'}
+
+
+
Age Rating: {game.esrbRating || 'NR'}
+
Genres: {game.genres?.slice(0, 4).join(', ') || 'N/A'}
+
+
+ +
+

{game.summary || 'No summary available for this entry.'}

+
+ +
+ onPlay(itemId)} onClick={() => onPlay(itemId)} className="shrink-0" focusKey="DETAILS_PLAY"> + {(focused) => ()} + + + {(focused) => ()} + +
+ onFavorite(itemId, !game.favorite)} onClick={() => onFavorite(itemId, !game.favorite)} focusKey="DETAILS_FAVORITE"> + {(focused) => ( + + )} + + + {(focused) => ()} + + + {(focused) => ()} + +
+
+
+
+ + {/* Media Galleria Container */} + {mediaItems.length > 0 && ( +
+
+
+ {mediaItems[activeMediaIndex].type === 'video' ? ( + mediaItems[activeMediaIndex].youtubeId ? ( + + ) : (
+
+ {mediaItems.map((item, idx) => ( + { setFocusZone(); setActiveMediaIndex(idx); }} onClick={() => setActiveMediaIndex(idx)} className="shrink-0" focusKey={`THUMB_${idx}`}> + {(focused) => ( +
+ {item.type === 'video' ? (
play_circle
) : ()} +
+ )} +
+ ))} +
+
+ +
+ )} +
+ ); +}; diff --git a/src/components/GameListItem.tsx b/src/components/GameListItem.tsx new file mode 100644 index 0000000..ad7f82a --- /dev/null +++ b/src/components/GameListItem.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Game } from '../api/client'; +import { FocusableItem } from './FocusableItem'; + +export const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, onFocus: () => void }) => ( + + {(focused) => ( +
+
+ +
+
+
+ + {game.title} + +
+
+ {game.size} +
+
+
+ )} +
+); diff --git a/src/components/GamesPage.tsx b/src/components/GamesPage.tsx index 843f359..98c18b5 100644 --- a/src/components/GamesPage.tsx +++ b/src/components/GamesPage.tsx @@ -1,905 +1,130 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation'; +import { GameListItem } from './GameListItem'; +import { GameDetailsPane } from './GameDetailsPane'; +import { AlphabetScroller } from './AlphabetScroller'; +import { AtelierExplorer } from './AtelierExplorer'; +import { PlatformItem } from './PlatformItem'; import { CollectionModal } from './CollectionModal'; import { ManualModal } from './ManualModal'; -import { rommApiClient, Platform, Game, RAAchievement, RAProgression } from '../api/client'; -import { useInputMode } from '../context/InputModeContext'; import { EmulatorOverlay } from './EmulatorOverlay'; - -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 AchievementList = ({ raId, achievements, userProgression }: { - raId?: number, - achievements?: RAAchievement[], - userProgression?: RAProgression | null -}) => { - if (!raId || !achievements || achievements.length === 0) { - return ( -
- trophy -
Achievements Unavailable
-
- ); - } - - const gameProgress = userProgression?.results.find(r => r.rom_ra_id === raId); - const earnedIds = new Set(gameProgress?.earned_achievements.map(a => String(a.id)) || []); - - const earnedCount = gameProgress?.num_awarded || 0; - const totalCount = achievements.length; - const progressPercent = totalCount > 0 ? (earnedCount / totalCount) * 100 : 0; - - return ( -
- {/* Header */} -
-
- workspace_premium -

RetroAchievements

-
-
- {earnedCount} / {totalCount} -
-
- - {/* Progress Bar */} -
-
-
- - {/* Achievement List */} -
- {achievements.map((achievement) => { - const isEarned = earnedIds.has(String(achievement.ra_id)); - const badgeId = achievement.badge_id || '00000'; - return ( -
- {/* Badge */} -
- -
- - {/* Text */} -
-
-

- {achievement.title} -

- - {achievement.points} pts - -
-

- {achievement.description} -

-
- - {/* Status Icon */} - {isEarned && ( - verified - )} -
- ); - })} -
-
- ); -}; - -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} -
- )} -
- ))} -
- ); -}; +import { rommApiClient } from '../api/client'; +import { useInputMode } from '../context/InputModeContext'; export const GamesPage = () => { const { mode } = useInputMode(); - const [selectedPlatformId, setSelectedPlatformId] = useState(null); - const [selectedGameId, setSelectedGameId] = useState(null); + const { platformId, romId } = useParams(); + const queryClient = useQueryClient(); + + const [activeMediaIndex, setActiveMediaIndex] = useState(0); const [playingGameId, setPlayingGameId] = useState(null); const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false); const [isManualModalOpen, setIsManualModalOpen] = useState(false); - const [activeMediaIndex, setActiveMediaIndex] = useState(0); - const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null); - const detailTimeoutRef = React.useRef(null); - const userQuery = useQuery({ - queryKey: ['userProfile'], - queryFn: () => rommApiClient.fetchCurrentUser(), - staleTime: 1000 * 60 * 5, // 5 minutes cache + const platformsQuery = useQuery({ + queryKey: ['platforms'], + queryFn: () => rommApiClient.fetchPlatforms() }); - 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 selectedPlatformId = platformId ? parseInt(platformId) : null; const gamesQuery = useQuery({ - queryKey: ['games', selectedPlatformId], - queryFn: () => selectedPlatformId ? rommApiClient.fetchGamesByPlatform(selectedPlatformId) : Promise.resolve([]), + queryKey: ['platformGames', selectedPlatformId], + queryFn: () => rommApiClient.fetchGamesByPlatform(selectedPlatformId!), enabled: !!selectedPlatformId }); const detailsQuery = useQuery({ - queryKey: ['gameDetails', selectedGameId], - queryFn: () => selectedGameId ? rommApiClient.fetchGameDetails(selectedGameId) : Promise.resolve(null), - enabled: !!selectedGameId + queryKey: ['gameDetails', romId], + queryFn: () => rommApiClient.fetchGameDetails(romId!), + enabled: !!romId, + staleTime: 1000 * 60 * 5, + }); + + const userQuery = useQuery({ + queryKey: ['currentUser'], + queryFn: () => rommApiClient.fetchCurrentUser() + }); + + const collectionsQuery = useQuery({ + queryKey: ['collections'], + queryFn: () => rommApiClient.fetchCollections() }); - 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: ['gameDetails', romId] }); 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(() => { + const activeLetters = 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('#'); - } + 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}`); - } - }; - - // Consolidate media for Galleria - const mediaItems = React.useMemo(() => { - if (!detailsQuery.data) return []; - const items: { type: 'video' | 'image', url: string, youtubeId?: string }[] = []; - - if (detailsQuery.data.youtubeId) { - items.push({ type: 'video', url: '', youtubeId: detailsQuery.data.youtubeId }); - } else if (detailsQuery.data.videoUrl) { - items.push({ type: 'video', url: detailsQuery.data.videoUrl }); - } - - if (detailsQuery.data.screenshots) { - detailsQuery.data.screenshots.forEach(s => { - items.push({ type: 'image', url: s }); - }); - } - return items; - }, [detailsQuery.data]); - - useEffect(() => { - setActiveMediaIndex(0); - }, [selectedGameId]); - - 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 */} -
- -
-
- - {/* Metadata Content */} -
- {/* Badges Row */} -
-
- Playable -
-
- {detailsQuery.data.players || '1 Player'} -
-
- - {/* Title */} -

- {detailsQuery.data.title} -

- - {/* Divider */} -
- - {/* Metadata List - Three Combined Rows */} -
- {/* Combined Row 1: Region & Release Date */} -
-
- Region: - {detailsQuery.data.regions?.join(', ') || 'N/A'} -
-
- Release date: - {detailsQuery.data.releaseDate || 'N/A'} -
-
- - {/* Combined Row 2: Franchise & Companies */} -
-
- Franchise: - {detailsQuery.data.collections?.join(', ') || 'N/A'} -
-
- Companies: - - {[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].slice(0, 3).join(', ') || 'Unknown Company'} - -
-
- - {/* Combined Row 3: Age Rating & Genres */} -
-
- Age Rating: - {detailsQuery.data.esrbRating || 'NR'} -
-
- Genres: - {detailsQuery.data.genres?.slice(0, 4).join(', ') || 'N/A'} -
-
-
- - {/* Brief Summary Container */} -
-

- {detailsQuery.data.summary || 'No summary available for this entry.'} -

-
-
- { if (mode === 'gamepad') setActiveZone('details'); }} - onEnterPress={() => { - if (detailsQuery.data) { - setPlayingGameId(detailsQuery.data.id); - } - }} - onClick={() => { - if (detailsQuery.data) { - setPlayingGameId(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 (mode === 'gamepad') setActiveZone('details'); }} - onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }} - onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }} - className="shrink-0" - focusKey="DETAILS_COLLECTION" - > - {(focused) => ( - - )} - - - { if (mode === 'gamepad') setActiveZone('details'); }} - onEnterPress={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }} - onClick={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }} - className="shrink-0" - focusKey="DETAILS_MANUAL" - > - {(focused) => ( - - )} - -
-
- -
-
- - {/* Media Galleria Container */} - {mediaItems.length > 0 && ( -
-
- {/* Active Media Slot */} -
- {mediaItems[activeMediaIndex].type === 'video' ? ( - mediaItems[activeMediaIndex].youtubeId ? ( - - ) : ( -
- - {/* Thumbnail Row */} -
- {mediaItems.map((item, idx) => ( - { - if (mode === 'gamepad') setActiveZone('details'); - setActiveMediaIndex(idx); - }} - onClick={() => setActiveMediaIndex(idx)} - className="shrink-0" - focusKey={`THUMB_${idx}`} - > - {(focused) => ( -
- {item.type === 'video' ? ( -
- play_circle -
- ) : ( - - )} - {activeMediaIndex === idx && ( -
- )} -
- )} -
- ))} -
-
- - {/* Achievement List Component */} - -
- )} -
- ) : selectedGameId ? ( -
-
-
progress_activity
-
Hydrating Metadata...
-
-
- ) : ( -
-
- videogame_asset -

Target selection required

-
-
- )} -
-
- - {/* Collection Management Modal Overlay */} + p.romCount > 0)} + items={gamesQuery.data || []} + isLoadingCategories={platformsQuery.isLoading} + isLoadingItems={gamesQuery.isLoading} + renderCategory={(p, active, onSelect) => ( + + )} + renderListItem={(game, active, onFocus) => ( + + )} + alphabet={activeLetters.length > 0 && ( + {}} /> + )} + renderDetails={(itemId, activeZone, setActiveZone) => ( + detailsQuery.data ? ( + favoriteMutation.mutate({ gameId: id, favorite: fav })} + onOpenCollection={() => setIsCollectionModalOpen(true)} + onOpenManual={() => setIsManualModalOpen(true)} + mode={mode} + /> + ) : ( +
progress_activity
Hydrating Metadata...
+ ) + )} + overlays={ + <> {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 - })} + onSelect={() => {}} // Integration logic preserved in future phase if needed + onCreate={() => {}} /> )} - - {playingGameId && ( - setPlayingGameId(null)} - /> - )} - + {playingGameId && setPlayingGameId(null)} />} {isManualModalOpen && detailsQuery.data && ( - setIsManualModalOpen(false)} - /> + setIsManualModalOpen(false)} /> )} -
-
+ + } + /> ); }; diff --git a/src/components/PlatformItem.tsx b/src/components/PlatformItem.tsx new file mode 100644 index 0000000..60250cc --- /dev/null +++ b/src/components/PlatformItem.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Platform } from '../api/client'; +import { FocusableItem } from './FocusableItem'; + +export const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => ( + + {(focused) => ( +
+
+ {platform.iconUrl ? ( + {platform.name} + ) : ( + sports_esports + )} +
+
+ {platform.name} +
+
+ )} +
+);