more game page development
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { CollectionModal } from './CollectionModal';
|
||||
import { rommApiClient, Platform, Game } from '../api/client';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
@@ -148,6 +149,7 @@ export const GamesPage = () => {
|
||||
const { mode } = useInputMode();
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
||||
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
||||
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
||||
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
|
||||
const detailTimeoutRef = React.useRef<any>(null);
|
||||
|
||||
@@ -193,6 +195,54 @@ export const GamesPage = () => {
|
||||
enabled: !!selectedGameId
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) =>
|
||||
rommApiClient.toggleFavorite(gameId, favorite),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
||||
}
|
||||
});
|
||||
|
||||
const collectionsQuery = useQuery({
|
||||
queryKey: ['collections'],
|
||||
queryFn: () => rommApiClient.fetchCollections()
|
||||
});
|
||||
|
||||
const addToCollectionMutation = useMutation({
|
||||
mutationFn: async ({ collectionId, gameId, name }: { collectionId: string, gameId: string, name: string }) => {
|
||||
const details = await rommApiClient.fetchCollectionDetails(collectionId);
|
||||
const ids: number[] = details.rom_ids || [];
|
||||
const numId = parseInt(gameId);
|
||||
if (!ids.includes(numId)) {
|
||||
ids.push(numId);
|
||||
return rommApiClient.updateCollection(collectionId, {
|
||||
name: name,
|
||||
rom_ids: ids
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
||||
setIsCollectionModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const createCollectionMutation = useMutation({
|
||||
mutationFn: async ({ name, gameId }: { name: string, gameId: string }) => {
|
||||
const newCol = await rommApiClient.createCollection(name);
|
||||
return rommApiClient.updateCollection(newCol.id, {
|
||||
name: newCol.name,
|
||||
rom_ids: [parseInt(gameId)]
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
||||
setIsCollectionModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlatforms = React.useMemo(() =>
|
||||
platformsQuery.data?.filter(p => p.romCount > 0) || [],
|
||||
[platformsQuery.data]
|
||||
@@ -232,7 +282,7 @@ export const GamesPage = () => {
|
||||
if (!gamesQuery.data) return [];
|
||||
const letters = new Set<string>();
|
||||
gamesQuery.data.forEach(game => {
|
||||
const firstChar = game.title.charAt(0).toUpperCase();
|
||||
const firstChar = (game.title || '').charAt(0).toUpperCase();
|
||||
if (/[A-Z]/.test(firstChar)) {
|
||||
letters.add(firstChar);
|
||||
} else {
|
||||
@@ -245,7 +295,7 @@ export const GamesPage = () => {
|
||||
const handleLetterPress = (letter: string) => {
|
||||
if (!gamesQuery.data) return;
|
||||
const game = gamesQuery.data.find(g => {
|
||||
const firstChar = g.title.charAt(0).toUpperCase();
|
||||
const firstChar = (g.title || '').charAt(0).toUpperCase();
|
||||
return letter === '#' ? !/[A-Z]/.test(firstChar) : firstChar === letter;
|
||||
});
|
||||
if (game) {
|
||||
@@ -277,7 +327,7 @@ export const GamesPage = () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (closestKey && mode !== 'mouse') {
|
||||
if (closestKey && mode !== 'mouse' && !isCollectionModalOpen) {
|
||||
console.log("[MagneticFocus] Setting focus to:", closestKey);
|
||||
setFocus(closestKey);
|
||||
}
|
||||
@@ -414,28 +464,30 @@ export const GamesPage = () => {
|
||||
<FocusContext.Provider value={detailsFocusKey}>
|
||||
<div ref={detailsRef} className={`flex-1 overflow-y-auto relative scrollbar-hoverable ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
||||
{detailsQuery.data ? (
|
||||
<div className="pt-[20px] pl-[20px] pr-16 pb-16">
|
||||
<div className="flex gap-[20px] items-start">
|
||||
<div className="pt-[40px] pl-[40px] pr-16 pb-16">
|
||||
<div className="flex gap-[40px] items-start h-[480px]">
|
||||
{/* Poster */}
|
||||
<div className="w-[320px] rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 sticky top-0 group">
|
||||
<div className="w-[320px] h-full rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 relative group">
|
||||
<img
|
||||
src={detailsQuery.data.coverUrl}
|
||||
className="w-full aspect-[2/3] object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
alt=""
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-60"></div>
|
||||
</div>
|
||||
|
||||
{/* Info Pane */}
|
||||
<div className="flex-1 pt-4">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-3 py-1 bg-[#2563eb] text-white text-[9px] font-black uppercase tracking-widest rounded-sm">PLAYABLE</span>
|
||||
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-black text-white tracking-tighter leading-none mb-6">{detailsQuery.data.title}</h1>
|
||||
<h1 className="text-5xl font-black text-white tracking-tighter leading-none mb-6 truncate whitespace-nowrap overflow-hidden">
|
||||
{detailsQuery.data.title}
|
||||
</h1>
|
||||
|
||||
{/* Row 1: Regions, Release Date & Rating */}
|
||||
{/* Metadata rows with consistent spacing (mb-2) */}
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Region:</span>
|
||||
@@ -457,7 +509,6 @@ export const GamesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Companies & Franchises */}
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Companies:</span>
|
||||
@@ -475,8 +526,7 @@ export const GamesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Genres */}
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Genres:</span>
|
||||
<span className="text-[11px] font-bold text-white/40 uppercase geist-mono tracking-wider italic">
|
||||
@@ -485,9 +535,8 @@ export const GamesPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Age Rating Text */}
|
||||
{detailsQuery.data.esrbRating && (
|
||||
<div className="flex items-center gap-2 mb-10">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Age Rating:</span>
|
||||
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
||||
{detailsQuery.data.esrbRating}
|
||||
@@ -495,15 +544,26 @@ export const GamesPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 mb-12">
|
||||
{/* Summary Section - Fixed Height & Scrollable */}
|
||||
<div className="bg-white/5 rounded-[8px] border border-white/5 p-4 flex-1 mb-6 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin pr-2">
|
||||
<p className="text-white/60 text-sm leading-relaxed font-medium">
|
||||
{detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Pushed to bottom, aligned with poster bottom */}
|
||||
<div className="flex gap-4 shrink-0">
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)}
|
||||
onClick={() => console.log("Play Game", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_PLAY"
|
||||
>
|
||||
{(focused) => (
|
||||
<button className={`flex items-center gap-3 px-8 py-4 rounded-full font-black uppercase tracking-tighter transition-all duration-300 shadow-xl ${focused ? 'bg-[#2563eb] text-white scale-105 shadow-[#2563eb]/40' : 'bg-white text-black hover:bg-white/90'}`}>
|
||||
<button className={`flex items-center gap-3 px-8 h-[54px] rounded-[12px] font-black uppercase tracking-tighter transition-all duration-300 shadow-xl ${focused ? 'bg-[#2563eb] text-white scale-105 shadow-[#2563eb]/40' : 'bg-white text-black hover:bg-white/90'}`}>
|
||||
<span className="material-symbols-outlined filled">play_arrow</span>
|
||||
Start Game
|
||||
</button>
|
||||
@@ -513,26 +573,84 @@ export const GamesPage = () => {
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
|
||||
onClick={() => console.log("Download", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_DOWNLOAD"
|
||||
>
|
||||
{(focused) => (
|
||||
<button className={`flex items-center gap-3 px-8 py-4 rounded-full font-black uppercase tracking-tighter border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<button className={`flex items-center gap-3 px-8 h-[54px] rounded-[12px] font-black uppercase tracking-tighter border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => {
|
||||
if (detailsQuery.data) {
|
||||
favoriteMutation.mutate({
|
||||
gameId: detailsQuery.data.id,
|
||||
favorite: !detailsQuery.data.favorite
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (detailsQuery.data) {
|
||||
favoriteMutation.mutate({
|
||||
gameId: detailsQuery.data.id,
|
||||
favorite: !detailsQuery.data.favorite
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_FAVORITE"
|
||||
>
|
||||
{(focused) => (
|
||||
<button
|
||||
title="Favorite"
|
||||
className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'} ${detailsQuery.data?.favorite ? '!border-[#2563eb] !text-[#2563eb] !bg-[#2563eb]/10' : ''}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined ${detailsQuery.data?.favorite ? 'filled' : ''}`} style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}>
|
||||
favorite
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
<FocusableItem
|
||||
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
||||
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_COLLECTION"
|
||||
>
|
||||
{(focused) => (
|
||||
<button title="Add to Collection" className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">library_add</span>
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
{detailsQuery.data?.manualUrl && (
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
|
||||
onClick={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_MANUAL"
|
||||
>
|
||||
{(focused) => (
|
||||
<button title="Open Manual" className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">menu_book</span>
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<section>
|
||||
<h3 className="text-[10px] font-black text-[#2563eb] uppercase tracking-[0.4em] mb-4">Brief Summary</h3>
|
||||
<p className="text-white/60 text-sm leading-relaxed font-medium">
|
||||
{detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,6 +671,22 @@ export const GamesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
{/* Collection Management Modal Overlay */}
|
||||
{isCollectionModalOpen && detailsQuery.data && (
|
||||
<CollectionModal
|
||||
collections={collectionsQuery.data || []}
|
||||
onClose={() => setIsCollectionModalOpen(false)}
|
||||
onSelect={(col) => addToCollectionMutation.mutate({
|
||||
collectionId: col.id,
|
||||
gameId: detailsQuery.data!.id,
|
||||
name: col.name
|
||||
})}
|
||||
onCreate={(name) => createCollectionMutation.mutate({
|
||||
name,
|
||||
gameId: detailsQuery.data!.id
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user