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}
-
@@ -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) => (
-