pretty much completed game metadata area

This commit is contained in:
roormonger
2026-03-26 22:13:07 -04:00
parent eabb1f7f82
commit 2bb3c150f5
2 changed files with 306 additions and 4 deletions

View File

@@ -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<RomSave[]> {
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<RomState[]> {
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<RomNote[]> {
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();
}
};

View File

@@ -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 = ({
<AchievementList raId={game.ra_id} achievements={game.merged_ra_metadata?.achievements} userProgression={user?.ra_progression} />
</div>
)}
{/* How Long To Beat Container */}
{game.hltb_metadata && (
<div className="mt-8 w-full bg-white/5 rounded-2xl px-6 py-4 border border-white/5 shadow-2xl relative overflow-hidden text-white">
<div className="flex items-center gap-2 mb-4 uppercase geist-mono font-black text-[0.75rem] tracking-widest text-white/30">
<span className="material-symbols-outlined text-[1.125rem]">schedule</span>
How Long To Beat
</div>
<div className="grid grid-cols-4 gap-4">
{(() => {
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 }) => (
<div className="flex flex-col items-center justify-center text-center">
<div className="text-[0.625rem] font-bold text-white/30 uppercase tracking-[0.2em] mb-2 geist-mono">{title}</div>
<div className="text-[1.75rem] font-black text-white leading-none mb-2 geist-mono tracking-tighter">
{formatTime(seconds)}
</div>
<div className="text-[0.5625rem] font-medium text-white/20 uppercase tracking-widest geist-mono">
{count ? `${count} players` : 'Global Average'}
</div>
</div>
);
return (
<>
<StatItem title="Main Story" seconds={game.hltb_metadata.main_story} count={game.hltb_metadata.main_story_count} />
<StatItem title="Main + Extra" seconds={game.hltb_metadata.main_plus_extra} count={game.hltb_metadata.main_plus_extra_count} />
<StatItem title="Completionist" seconds={game.hltb_metadata.completionist} count={game.hltb_metadata.completionist_count} />
<StatItem title="All Styles" seconds={game.hltb_metadata.all_styles} count={game.hltb_metadata.all_styles_count} />
</>
);
})()}
</div>
</div>
)}
{/* Saves, States and Notes Grid */}
<div className="grid grid-cols-12 gap-8 mt-12 mb-12">
{/* Tabbed Saves & States Section */}
<div className="col-span-6 h-[30rem] bg-white/5 rounded-[12px] pt-1 px-5 pb-5 border border-white/5 flex flex-col overflow-hidden relative">
<div className="flex items-center justify-between mb-1 px-2 h-10">
<div className="flex items-center gap-6">
<FocusableItem
onFocus={setFocusZone}
onClick={() => setActiveTab('saves')}
onEnterPress={() => setActiveTab('saves')}
focusKey="SAVES_TAB"
>
{(focused) => (
<button className={`geist-mono font-black text-[0.75rem] tracking-[0.2em] transition-all relative py-1 ${activeTab === 'saves' ? 'text-white' : 'text-white/30 hover:text-white/50'} ${focused ? 'scale-110 text-[#2563eb]' : ''}`}>
SAVES
{activeTab === 'saves' && <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#2563eb] shadow-[0_0_10px_rgba(37,99,235,0.5)]" />}
</button>
)}
</FocusableItem>
<FocusableItem
onFocus={setFocusZone}
onClick={() => setActiveTab('states')}
onEnterPress={() => setActiveTab('states')}
focusKey="STATES_TAB"
>
{(focused) => (
<button className={`geist-mono font-black text-[0.75rem] tracking-[0.2em] transition-all relative py-1 ${activeTab === 'states' ? 'text-white' : 'text-white/30 hover:text-white/50'} ${focused ? 'scale-110 text-[#2563eb]' : ''}`}>
STATES
{activeTab === 'states' && <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#2563eb] shadow-[0_0_10px_rgba(37,99,235,0.5)]" />}
</button>
)}
</FocusableItem>
</div>
<div className="flex items-center gap-2">
<FocusableItem onFocus={setFocusZone} focusKey="SAVES_UPLOAD">
{(focused) => (
<button className={`w-10 h-10 flex items-center justify-center rounded-lg transition-all ${focused ? 'bg-[#2563eb]/20 text-[#2563eb] ring-1 ring-[#2563eb] scale-110 shadow-lg' : 'text-white/40 hover:text-white/60 hover:bg-white/5'}`}>
<span className="material-symbols-outlined text-[1.25rem]">cloud_upload</span>
</button>
)}
</FocusableItem>
<FocusableItem onFocus={setFocusZone} focusKey="SAVES_DOWNLOAD">
{(focused) => (
<button className={`w-10 h-10 flex items-center justify-center rounded-lg transition-all ${focused ? 'bg-[#2563eb]/20 text-[#2563eb] ring-1 ring-[#2563eb] scale-110 shadow-lg' : 'text-white/40 hover:text-white/60 hover:bg-white/5'}`}>
<span className="material-symbols-outlined text-[1.25rem]">download</span>
</button>
)}
</FocusableItem>
<FocusableItem onFocus={setFocusZone} focusKey="SAVES_DELETE">
{(focused) => (
<button className={`w-10 h-10 flex items-center justify-center rounded-lg transition-all ${focused ? 'bg-red-500/20 text-red-500 ring-1 ring-red-500 scale-110 shadow-lg' : 'text-white/40 hover:text-red-400 hover:bg-red-500/10'}`}>
<span className="material-symbols-outlined text-[1.25rem]">delete</span>
</button>
)}
</FocusableItem>
</div>
</div>
<div className="border-t border-white/5 mb-6"></div>
<div className="flex-1 overflow-y-auto scrollbar-hoverable pr-2">
{activeTab === 'saves' ? (
(!savesQuery.data || savesQuery.data.length === 0) ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-20">
<span className="material-symbols-outlined text-5xl mb-4">cloud_off</span>
<p className="geist-mono uppercase text-[0.625rem] tracking-widest">No saves found</p>
</div>
) : (
<div className="space-y-4">
{savesQuery.data.map(save => (
<div key={save.id} className="group/item flex items-center justify-between p-4 bg-white/[0.02] rounded-xl border border-white/5 hover:bg-white/[0.05] transition-all">
<div className="flex items-center gap-4 overflow-hidden">
<span className="material-symbols-outlined text-white/30">draft</span>
<div className="overflow-hidden">
<div className="text-sm font-bold truncate text-white/80">{save.file_name}</div>
<div className="text-[0.625rem] geist-mono text-white/30 uppercase tracking-wider">
{save.emulator} Slot {save.slot ?? 0}
</div>
</div>
</div>
<div className="text-[0.625rem] geist-mono text-white/20">
{(save.file_size_bytes / 1024).toFixed(1)} KB
</div>
</div>
))}
</div>
)
) : (
(!statesQuery.data || statesQuery.data.length === 0) ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-20">
<span className="material-symbols-outlined text-5xl mb-4">history</span>
<p className="geist-mono uppercase text-[0.625rem] tracking-widest">No states found</p>
</div>
) : (
<div className="space-y-4">
{statesQuery.data.map(state => (
<div key={state.id} className="group/item flex items-center justify-between p-4 bg-white/[0.02] rounded-xl border border-white/5 hover:bg-white/[0.05] transition-all">
<div className="flex items-center gap-4 overflow-hidden">
<span className="material-symbols-outlined text-white/30">history</span>
<div className="overflow-hidden">
<div className="text-sm font-bold truncate text-white/80">{state.file_name}</div>
<div className="text-[0.625rem] geist-mono text-white/30 uppercase tracking-wider">
{state.emulator}
</div>
</div>
</div>
<div className="text-[0.625rem] geist-mono text-white/20">
{new Date(state.created_at).toLocaleDateString()}
</div>
</div>
))}
</div>
)
)}
</div>
</div>
{/* Personal Notes Section */}
<div className="col-span-6 h-[30rem] bg-white/5 rounded-[12px] pt-1 px-5 pb-5 border border-white/5 flex flex-col overflow-hidden relative">
<div className="flex items-center justify-between mb-1 px-2 h-10">
<div className="flex items-center gap-3 uppercase geist-mono font-black text-[0.75rem] tracking-[0.2em] text-white">
<span className="material-symbols-outlined text-[1.25rem]">edit_note</span>
Personal Notes
</div>
<div className="flex items-center gap-2">
<FocusableItem onFocus={setFocusZone} focusKey="ADD_NOTE">
{(focused) => (
<button className={`w-10 h-10 flex items-center justify-center rounded-lg transition-all ${focused ? 'bg-[#2563eb]/20 text-[#2563eb] ring-1 ring-[#2563eb] scale-110 shadow-lg' : 'text-white/40 hover:text-white/60 hover:bg-white/5'}`}>
<span className="material-symbols-outlined text-[1.25rem]">add</span>
</button>
)}
</FocusableItem>
</div>
</div>
<div className="border-t border-white/5 mb-6"></div>
<div className="flex-1 overflow-y-auto scrollbar-hoverable pr-2">
{!notesQuery.data || notesQuery.data.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-20">
<span className="material-symbols-outlined text-5xl mb-4">note_stack</span>
<p className="geist-mono uppercase text-[0.625rem] tracking-widest">No entries recorded</p>
</div>
) : (
<div className="space-y-8">
{notesQuery.data.map(note => (
<div key={note.id} className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-lg font-bold text-white/80 tracking-tight">{note.title}</div>
<div className="text-[0.625rem] geist-mono text-white/20 uppercase">
{new Date(note.updated_at).toLocaleDateString()}
</div>
</div>
<div className="text-sm leading-relaxed text-white/50 bg-white/[0.02] p-5 rounded-xl border border-white/5">
{note.content}
</div>
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{note.tags.map(tag => (
<span key={tag} className="px-2 py-0.5 bg-blue-500/10 border border-blue-500/20 rounded text-[0.625rem] geist-mono text-blue-400 uppercase tracking-widest">
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};