pretty much completed game metadata area
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user