diff --git a/src/api/client.ts b/src/api/client.ts index 16c6fa0..cee0f0a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -14,6 +14,43 @@ export interface Platform { iconUrl?: string; } +export interface RAAchievement { + ra_id: number | null; + title: string | null; + description: string | null; + points: number | null; + num_awarded: number | null; + num_awarded_hardcore: number | null; + badge_id: string | null; +} + +export interface RomRAMetadata { + first_release_date?: number | null; + genres?: string[]; + companies?: string[]; + achievements: RAAchievement[]; +} + +export interface EarnedAchievement { + id: string; + date: string; + date_hardcore?: string; +} + +export interface RAProgressionResult { + rom_ra_id: number | null; + max_possible: number | null; + num_awarded: number | null; + num_awarded_hardcore: number | null; + most_recent_awarded_date?: string | null; + earned_achievements: EarnedAchievement[]; +} + +export interface RAProgression { + total: number; + results: RAProgressionResult[]; +} + export interface DetailedGame extends Game { summary?: string; developers?: string[]; @@ -35,6 +72,8 @@ export interface DetailedGame extends Game { platformId?: number; videoUrl?: string; // Direct video link youtubeId?: string; // YouTube ID for embed + ra_id?: number; + merged_ra_metadata?: RomRAMetadata; } export interface RommCollection { @@ -51,6 +90,8 @@ export interface UserProfile { username: string; avatarUrl?: string; roleName: string; + ra_username?: string | null; + ra_progression?: RAProgression | null; } // Function to safely extract base URL @@ -341,6 +382,8 @@ export const rommApiClient = { platformId: json.platform_id, videoUrl: getFullImageUrl(json.url_video || json.ss_metadata?.video_url), youtubeId: json.youtube_video_id || json.igdb_metadata?.youtube_video_id || json.launchbox_metadata?.youtube_video_id, + ra_id: json.ra_id, + merged_ra_metadata: json.merged_ra_metadata, screenshots: Array.from(new Set([ ...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)), ...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), @@ -475,6 +518,8 @@ export const rommApiClient = { username: json.username || 'Unknown', avatarUrl: constructedAvatarUrl, roleName: json.role?.role_name || json.role?.name || String(json.role) || 'User', + ra_username: json.ra_username, + ra_progression: json.ra_progression, }; } }; diff --git a/src/components/GamesPage.tsx b/src/components/GamesPage.tsx index 33726bd..843f359 100644 --- a/src/components/GamesPage.tsx +++ b/src/components/GamesPage.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation'; import { CollectionModal } from './CollectionModal'; import { ManualModal } from './ManualModal'; -import { rommApiClient, Platform, Game } from '../api/client'; +import { rommApiClient, Platform, Game, RAAchievement, RAProgression } from '../api/client'; import { useInputMode } from '../context/InputModeContext'; import { EmulatorOverlay } from './EmulatorOverlay'; @@ -52,6 +52,94 @@ const FocusableItem = ({ onFocus, onEnterPress, onClick, children, className, fo ); }; +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) => ( -
-
+
@@ -76,7 +164,7 @@ const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, acti sports_esports )}
-
{platform.name} @@ -97,7 +185,7 @@ const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, > {(focused) => (
-
+
@@ -106,7 +194,7 @@ const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, {game.title}
-
+
{game.size}
@@ -135,7 +223,7 @@ const AlphabetScroller = ({ scrollOptions={{ behavior: 'smooth', block: 'center' }} > {(focused) => ( -
{letter} @@ -158,6 +246,12 @@ export const GamesPage = () => { 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 { ref: platformsRef, focusKey: platformsFocusKey } = useFocusable({ focusKey: 'PLATFORMS_ZONE', trackChildren: true, @@ -446,12 +540,12 @@ export const GamesPage = () => { {/* Main Split Layout */}
{/* Left Column: Game List + Alphabet */} -
+
{gamesQuery.isLoading ? ( -
Retrieving index...
+
Retrieving index...
) : ( gamesQuery.data?.map(game => ( {
{detailsQuery.data ? ( -
-
+
+
{/* Poster */} -
+
{ {/* Metadata Content */}
{/* Badges Row */} -
-
+
+
Playable
-
+
{detailsQuery.data.players || '1 Player'}
{/* Title */} -

+

{detailsQuery.data.title}

@@ -525,12 +619,12 @@ export const GamesPage = () => {
{/* Metadata List - Three Combined Rows */} -
+
{/* Combined Row 1: Region & Release Date */}
- Region: - {detailsQuery.data.regions?.join(', ') || 'N/A'} + Region: + {detailsQuery.data.regions?.join(', ') || 'N/A'}
Release date: @@ -541,8 +635,8 @@ export const GamesPage = () => { {/* Combined Row 2: Franchise & Companies */}
- Franchise: - {detailsQuery.data.collections?.join(', ') || 'N/A'} + Franchise: + {detailsQuery.data.collections?.join(', ') || 'N/A'}
Companies: @@ -555,7 +649,7 @@ export const GamesPage = () => { {/* Combined Row 3: Age Rating & Genres */}
- Age Rating: + Age Rating: {detailsQuery.data.esrbRating || 'NR'}
@@ -566,8 +660,8 @@ export const GamesPage = () => {
{/* Brief Summary Container */} -
-

+

+

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

@@ -588,8 +682,8 @@ export const GamesPage = () => { focusKey="DETAILS_PLAY" > {(focused) => ( - )} @@ -603,8 +697,8 @@ export const GamesPage = () => { focusKey="DETAILS_DOWNLOAD" > {(focused) => ( - )} @@ -635,12 +729,12 @@ export const GamesPage = () => { {(focused) => ( )} @@ -674,8 +768,8 @@ export const GamesPage = () => { focusKey="DETAILS_MANUAL" > {(focused) => ( - )} @@ -687,10 +781,10 @@ export const GamesPage = () => { {/* Media Galleria Container */} {mediaItems.length > 0 && ( -
-
+
+
{/* Active Media Slot */} -
+
{mediaItems[activeMediaIndex].type === 'video' ? ( mediaItems[activeMediaIndex].youtubeId ? (