pretty much completed game metadata area
This commit is contained in:
@@ -24,6 +24,18 @@ export interface RAAchievement {
|
|||||||
badge_id: string | null;
|
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 {
|
export interface RomRAMetadata {
|
||||||
first_release_date?: number | null;
|
first_release_date?: number | null;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
@@ -74,6 +86,40 @@ export interface DetailedGame extends Game {
|
|||||||
youtubeId?: string; // YouTube ID for embed
|
youtubeId?: string; // YouTube ID for embed
|
||||||
ra_id?: number;
|
ra_id?: number;
|
||||||
merged_ra_metadata?: RomRAMetadata;
|
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 {
|
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,
|
youtubeId: json.youtube_video_id || json.igdb_metadata?.youtube_video_id || json.launchbox_metadata?.youtube_video_id,
|
||||||
ra_id: json.ra_id,
|
ra_id: json.ra_id,
|
||||||
merged_ra_metadata: json.merged_ra_metadata,
|
merged_ra_metadata: json.merged_ra_metadata,
|
||||||
|
hltb_id: json.hltb_id,
|
||||||
|
hltb_metadata: json.hltb_metadata,
|
||||||
screenshots: Array.from(new Set([
|
screenshots: Array.from(new Set([
|
||||||
...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)),
|
...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)),
|
||||||
...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''),
|
...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''),
|
||||||
@@ -521,5 +569,23 @@ export const rommApiClient = {
|
|||||||
ra_username: json.ra_username,
|
ra_username: json.ra_username,
|
||||||
ra_progression: json.ra_progression,
|
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 { useState } from 'react';
|
||||||
import { DetailedGame, RAProgression, UserProfile } from '../api/client';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { rommApiClient, DetailedGame, UserProfile } from '../api/client';
|
||||||
import { FocusableItem } from './FocusableItem';
|
import { FocusableItem } from './FocusableItem';
|
||||||
import { AchievementList } from './AchievementList';
|
import { AchievementList } from './AchievementList';
|
||||||
|
|
||||||
@@ -9,7 +10,6 @@ interface GameDetailsPaneProps {
|
|||||||
user?: UserProfile | null;
|
user?: UserProfile | null;
|
||||||
activeMediaIndex: number;
|
activeMediaIndex: number;
|
||||||
setActiveMediaIndex: (idx: number) => void;
|
setActiveMediaIndex: (idx: number) => void;
|
||||||
activeZone: string;
|
|
||||||
setActiveZone: (zone: string) => void;
|
setActiveZone: (zone: string) => void;
|
||||||
onPlay: (id: string) => void;
|
onPlay: (id: string) => void;
|
||||||
onFavorite: (id: string, fav: boolean) => void;
|
onFavorite: (id: string, fav: boolean) => void;
|
||||||
@@ -24,7 +24,6 @@ export const GameDetailsPane = ({
|
|||||||
user,
|
user,
|
||||||
activeMediaIndex,
|
activeMediaIndex,
|
||||||
setActiveMediaIndex,
|
setActiveMediaIndex,
|
||||||
activeZone,
|
|
||||||
setActiveZone,
|
setActiveZone,
|
||||||
onPlay,
|
onPlay,
|
||||||
onFavorite,
|
onFavorite,
|
||||||
@@ -32,6 +31,25 @@ export const GameDetailsPane = ({
|
|||||||
onOpenManual,
|
onOpenManual,
|
||||||
mode
|
mode
|
||||||
}: GameDetailsPaneProps) => {
|
}: 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 }[] = [];
|
const mediaItems: { type: 'video' | 'image', url: string, youtubeId?: string }[] = [];
|
||||||
if (game.youtubeId) mediaItems.push({ type: 'video', url: '', youtubeId: game.youtubeId });
|
if (game.youtubeId) mediaItems.push({ type: 'video', url: '', youtubeId: game.youtubeId });
|
||||||
else if (game.videoUrl) mediaItems.push({ type: 'video', url: game.videoUrl });
|
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} />
|
<AchievementList raId={game.ra_id} achievements={game.merged_ra_metadata?.achievements} userProgression={user?.ra_progression} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user