From 2bb3c150f54917f5423faf00243ba87e553784a0 Mon Sep 17 00:00:00 2001 From: roormonger <34205054+roormonger@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:13:07 -0400 Subject: [PATCH] pretty much completed game metadata area --- src/api/client.ts | 66 ++++++++ src/components/GameDetailsPane.tsx | 244 ++++++++++++++++++++++++++++- 2 files changed, 306 insertions(+), 4 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index cee0f0a..a5d20b5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -24,6 +24,18 @@ export interface RAAchievement { badge_id: string | null; } +export interface HLTBMetadata { + main_story: number; + main_plus_extra: number; + completionist: number; + all_styles?: number; + main_story_count?: number; + main_plus_extra_count?: number; + completionist_count?: number; + all_styles_count?: number; + image?: string; +} + export interface RomRAMetadata { first_release_date?: number | null; genres?: string[]; @@ -74,6 +86,40 @@ export interface DetailedGame extends Game { youtubeId?: string; // YouTube ID for embed ra_id?: number; merged_ra_metadata?: RomRAMetadata; + hltb_id?: number; + hltb_metadata?: HLTBMetadata; + notes?: RomNote[]; + saves?: RomSave[]; + states?: RomState[]; +} + +export interface RomSave { + id: number; + file_name: string; + file_size_bytes: number; + emulator: string; + slot?: number; + created_at: string; + updated_at: string; +} + +export interface RomState { + id: number; + file_name: string; + emulator: string; + created_at: string; + updated_at: string; +} + +export interface RomNote { + id: number; + title: string; + content: string; + is_public: boolean; + tags?: string[]; + username: string; + created_at: string; + updated_at: string; } export interface RommCollection { @@ -384,6 +430,8 @@ export const rommApiClient = { 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, + hltb_id: json.hltb_id, + hltb_metadata: json.hltb_metadata, screenshots: Array.from(new Set([ ...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)), ...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), @@ -521,5 +569,23 @@ export const rommApiClient = { ra_username: json.ra_username, ra_progression: json.ra_progression, }; + }, + + async fetchRomSaves(id: string): Promise { + const res = await fetch(`${this.apiBase}/saves?rom_id=${id}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch saves.'); + return res.json(); + }, + + async fetchRomStates(id: string): Promise { + const res = await fetch(`${this.apiBase}/states?rom_id=${id}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch states.'); + return res.json(); + }, + + async fetchRomNotes(id: string): Promise { + const res = await fetch(`${this.apiBase}/roms/${id}/notes`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch notes.'); + return res.json(); } }; diff --git a/src/components/GameDetailsPane.tsx b/src/components/GameDetailsPane.tsx index dc9b844..18f5f55 100644 --- a/src/components/GameDetailsPane.tsx +++ b/src/components/GameDetailsPane.tsx @@ -1,5 +1,6 @@ -import React from 'react'; -import { DetailedGame, RAProgression, UserProfile } from '../api/client'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { rommApiClient, DetailedGame, UserProfile } from '../api/client'; import { FocusableItem } from './FocusableItem'; import { AchievementList } from './AchievementList'; @@ -9,7 +10,6 @@ interface GameDetailsPaneProps { 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; @@ -24,7 +24,6 @@ export const GameDetailsPane = ({ user, activeMediaIndex, setActiveMediaIndex, - activeZone, setActiveZone, onPlay, onFavorite, @@ -32,6 +31,25 @@ export const GameDetailsPane = ({ onOpenManual, mode }: GameDetailsPaneProps) => { + const [activeTab, setActiveTab] = useState<'saves' | 'states'>('saves'); + const savesQuery = useQuery({ + queryKey: ['gameSaves', game.id], + queryFn: () => rommApiClient.fetchRomSaves(game.id), + enabled: !!game.id + }); + + const statesQuery = useQuery({ + queryKey: ['gameStates', game.id], + queryFn: () => rommApiClient.fetchRomStates(game.id), + enabled: !!game.id + }); + + const notesQuery = useQuery({ + queryKey: ['gameNotes', game.id], + queryFn: () => rommApiClient.fetchRomNotes(game.id), + enabled: !!game.id + }); + 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 }); @@ -130,6 +148,224 @@ export const GameDetailsPane = ({ )} + + {/* How Long To Beat Container */} + {game.hltb_metadata && ( +
+
+ schedule + How Long To Beat +
+ +
+ {(() => { + const formatTime = (seconds?: number) => { + if (!seconds) return '--'; + const hours = seconds / 3600; + if (hours >= 100) return `${Math.round(hours)}h`; + const formatted = Math.round(hours * 10) / 10; + return `${formatted}h`; + }; + + const StatItem = ({ title, seconds, count }: { title: string, seconds?: number, count?: number }) => ( +
+
{title}
+
+ {formatTime(seconds)} +
+
+ {count ? `${count} players` : 'Global Average'} +
+
+ ); + + return ( + <> + + + + + + ); + })()} +
+
+ )} + + {/* Saves, States and Notes Grid */} +
+ {/* Tabbed Saves & States Section */} +
+
+
+ setActiveTab('saves')} + onEnterPress={() => setActiveTab('saves')} + focusKey="SAVES_TAB" + > + {(focused) => ( + + )} + + setActiveTab('states')} + onEnterPress={() => setActiveTab('states')} + focusKey="STATES_TAB" + > + {(focused) => ( + + )} + +
+ +
+ + {(focused) => ( + + )} + + + {(focused) => ( + + )} + + + {(focused) => ( + + )} + +
+
+ +
+ +
+ {activeTab === 'saves' ? ( + (!savesQuery.data || savesQuery.data.length === 0) ? ( +
+ cloud_off +

No saves found

+
+ ) : ( +
+ {savesQuery.data.map(save => ( +
+
+ draft +
+
{save.file_name}
+
+ {save.emulator} • Slot {save.slot ?? 0} +
+
+
+
+ {(save.file_size_bytes / 1024).toFixed(1)} KB +
+
+ ))} +
+ ) + ) : ( + (!statesQuery.data || statesQuery.data.length === 0) ? ( +
+ history +

No states found

+
+ ) : ( +
+ {statesQuery.data.map(state => ( +
+
+ history +
+
{state.file_name}
+
+ {state.emulator} +
+
+
+
+ {new Date(state.created_at).toLocaleDateString()} +
+
+ ))} +
+ ) + )} +
+
+ + {/* Personal Notes Section */} +
+
+
+ edit_note + Personal Notes +
+ +
+ + {(focused) => ( + + )} + +
+
+ +
+ +
+ {!notesQuery.data || notesQuery.data.length === 0 ? ( +
+ note_stack +

No entries recorded

+
+ ) : ( +
+ {notesQuery.data.map(note => ( +
+
+
{note.title}
+
+ {new Date(note.updated_at).toLocaleDateString()} +
+
+
+ {note.content} +
+ {note.tags && note.tags.length > 0 && ( +
+ {note.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ ))} +
+ )} +
+
+
); };