694 lines
32 KiB
TypeScript
694 lines
32 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
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';
|
|
|
|
interface FocusableItemProps {
|
|
onFocus: () => void;
|
|
onEnterPress?: () => void;
|
|
children: (focused: boolean) => React.ReactNode;
|
|
className?: string;
|
|
focusKey?: string;
|
|
scrollOptions?: ScrollIntoViewOptions;
|
|
active?: boolean;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
const FocusableItem = ({ onFocus, onEnterPress, onClick, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => {
|
|
const { mode } = useInputMode();
|
|
const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({
|
|
onFocus,
|
|
onEnterPress,
|
|
focusKey
|
|
});
|
|
|
|
const focused = mode !== 'mouse' ? rawFocused : false;
|
|
|
|
useEffect(() => {
|
|
if (mode !== 'mouse' && (focused || active) && ref.current) {
|
|
if (scrollOptions) {
|
|
ref.current.scrollIntoView(scrollOptions);
|
|
} else {
|
|
ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
|
}
|
|
}
|
|
}, [focused, active, mode, scrollOptions]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-focusable-key={internalFocusKey}
|
|
className={`${className} ${focused ? 'focused' : ''}`}
|
|
onClick={() => {
|
|
if (onClick) onClick();
|
|
}}
|
|
>
|
|
{children(focused)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => (
|
|
<FocusableItem
|
|
onFocus={onSelect}
|
|
onClick={onSelect}
|
|
className="shrink-0"
|
|
focusKey={`PLATFORM_${platform.id}`}
|
|
active={active}
|
|
>
|
|
{(focused) => (
|
|
<div className="flex flex-col items-center gap-2 group cursor-pointer w-[110px]">
|
|
<div className={`w-[84px] h-[84px] rounded-[12px] transition-all duration-500 border-2 flex items-center justify-center p-3
|
|
${focused || active ? 'border-[#2563eb] bg-transparent scale-110 z-20 shadow-[0_5px_15px_rgba(37,99,235,0.2)]' :
|
|
'bg-white/5 border-white/5 group-hover:border-white/10'}`}
|
|
>
|
|
{platform.iconUrl ? (
|
|
<img
|
|
src={platform.iconUrl}
|
|
alt={platform.name}
|
|
className={`w-full h-full object-contain transition-all duration-500 ${focused || active ? 'scale-110 brightness-100' : 'grayscale brightness-200 opacity-30 group-hover:opacity-60'}`}
|
|
/>
|
|
) : (
|
|
<span className={`material-symbols-outlined text-3xl transition-colors ${focused || active ? 'text-white' : 'text-white/20'}`}>sports_esports</span>
|
|
)}
|
|
</div>
|
|
<div className={`text-[9px] font-black uppercase geist-mono tracking-widest text-center transition-all duration-300 w-full px-2 truncate
|
|
${focused || active ? 'text-[#2563eb] translate-y-1' : 'text-white/20'}`}
|
|
>
|
|
{platform.name}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FocusableItem>
|
|
);
|
|
|
|
const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, onFocus: () => void }) => (
|
|
<FocusableItem
|
|
onFocus={onFocus}
|
|
onClick={onFocus}
|
|
className="w-full"
|
|
focusKey={`GAME_${game.id}`}
|
|
scrollOptions={{ behavior: 'smooth', block: 'nearest' }}
|
|
active={active}
|
|
>
|
|
{(focused) => (
|
|
<div className={`flex items-center gap-4 px-6 py-2.5 cursor-pointer transition-all duration-300 border-l-4 ${focused ? 'bg-[#2563eb]/20 border-[#2563eb] z-10' : active ? 'bg-[#2563eb]/10 border-[#2563eb]/70' : 'border-transparent hover:bg-white/5'}`}>
|
|
<div className="w-8 h-11 bg-white/5 rounded-[4px] overflow-hidden shrink-0 border border-white/5">
|
|
<img src={game.coverUrl} alt="" className="w-full h-full object-cover" />
|
|
</div>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<div className="overflow-hidden whitespace-nowrap">
|
|
<span className={`text-xs font-bold transition-colors ${focused ? 'marquee-active text-white' : active ? 'text-white' : 'text-white/60'}`}>
|
|
{game.title}
|
|
</span>
|
|
</div>
|
|
<div className="text-[10px] text-white/40 font-medium geist-mono uppercase tracking-tight">
|
|
{game.size}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FocusableItem>
|
|
);
|
|
|
|
const AlphabetScroller = ({
|
|
letters,
|
|
onLetterPress
|
|
}: {
|
|
letters: string[],
|
|
onLetterPress: (letter: string) => void
|
|
}) => {
|
|
return (
|
|
<div className="w-10 flex flex-col items-center justify-center py-8 bg-black/40 border-l border-white/5 gap-2 select-none">
|
|
{letters.map(letter => (
|
|
<FocusableItem
|
|
key={letter}
|
|
onFocus={() => {}}
|
|
onEnterPress={() => onLetterPress(letter)}
|
|
onClick={() => onLetterPress(letter)}
|
|
className="w-full flex justify-center py-1"
|
|
focusKey={`LETTER_${letter}`}
|
|
scrollOptions={{ behavior: 'smooth', block: 'center' }}
|
|
>
|
|
{(focused) => (
|
|
<div className={`text-[13px] font-black geist-mono transition-all duration-200
|
|
${focused ? 'text-[#2563eb] scale-125' : 'text-white/30 hover:text-white/70'}`}
|
|
>
|
|
{letter}
|
|
</div>
|
|
)}
|
|
</FocusableItem>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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);
|
|
|
|
const { ref: platformsRef, focusKey: platformsFocusKey } = useFocusable({
|
|
focusKey: 'PLATFORMS_ZONE',
|
|
trackChildren: true,
|
|
preferredChildFocusKey: selectedPlatformId ? `PLATFORM_${selectedPlatformId}` : undefined,
|
|
onBlur: () => setActiveZone(prev => prev === 'platforms' ? null : prev)
|
|
});
|
|
|
|
const { ref: gamesRef, focusKey: gamesFocusKey } = useFocusable({
|
|
focusKey: 'GAMES_ZONE',
|
|
trackChildren: true,
|
|
preferredChildFocusKey: selectedGameId ? `GAME_${selectedGameId}` : undefined,
|
|
onBlur: () => setActiveZone(prev => prev === 'games' ? null : prev)
|
|
});
|
|
|
|
const { ref: alphabetRef, focusKey: alphabetFocusKey } = useFocusable({
|
|
focusKey: 'ALPHABET_ZONE',
|
|
trackChildren: true
|
|
});
|
|
|
|
const { ref: detailsRef, focusKey: detailsFocusKey } = useFocusable({
|
|
focusKey: 'DETAILS_ZONE',
|
|
trackChildren: true
|
|
});
|
|
|
|
// Queries
|
|
const platformsQuery = useQuery({
|
|
queryKey: ['platforms'],
|
|
queryFn: () => rommApiClient.fetchPlatforms()
|
|
});
|
|
|
|
const gamesQuery = useQuery({
|
|
queryKey: ['games', selectedPlatformId],
|
|
queryFn: () => selectedPlatformId ? rommApiClient.fetchGamesByPlatform(selectedPlatformId) : Promise.resolve([]),
|
|
enabled: !!selectedPlatformId
|
|
});
|
|
|
|
const detailsQuery = useQuery({
|
|
queryKey: ['gameDetails', selectedGameId],
|
|
queryFn: () => selectedGameId ? rommApiClient.fetchGameDetails(selectedGameId) : Promise.resolve(null),
|
|
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]
|
|
);
|
|
|
|
// Auto-select first items
|
|
useEffect(() => {
|
|
if (filteredPlatforms.length > 0 && selectedPlatformId === null) {
|
|
setSelectedPlatformId(filteredPlatforms[0].id);
|
|
}
|
|
}, [filteredPlatforms, selectedPlatformId]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== 'mouse' && (gamesQuery.data?.length ?? 0) > 0 && !selectedGameId) {
|
|
setSelectedGameId(gamesQuery.data?.[0]?.id ?? null);
|
|
}
|
|
}, [gamesQuery.data, selectedGameId, mode]);
|
|
|
|
const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => {
|
|
if (mode !== 'mouse') {
|
|
if (!options.skipZoneChange) {
|
|
setActiveZone('platforms');
|
|
}
|
|
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
|
detailTimeoutRef.current = setTimeout(() => {
|
|
setSelectedPlatformId(id);
|
|
setSelectedGameId(null);
|
|
}, 150);
|
|
} else {
|
|
setSelectedPlatformId(id);
|
|
setSelectedGameId(null);
|
|
}
|
|
};
|
|
|
|
// Alphabet Logic
|
|
const activeLetters = React.useMemo(() => {
|
|
if (!gamesQuery.data) return [];
|
|
const letters = new Set<string>();
|
|
gamesQuery.data.forEach(game => {
|
|
const firstChar = (game.title || '').charAt(0).toUpperCase();
|
|
if (/[A-Z]/.test(firstChar)) {
|
|
letters.add(firstChar);
|
|
} else {
|
|
letters.add('#');
|
|
}
|
|
});
|
|
return Array.from(letters).sort();
|
|
}, [gamesQuery.data]);
|
|
|
|
const handleLetterPress = (letter: string) => {
|
|
if (!gamesQuery.data) return;
|
|
const game = gamesQuery.data.find(g => {
|
|
const firstChar = (g.title || '').charAt(0).toUpperCase();
|
|
return letter === '#' ? !/[A-Z]/.test(firstChar) : firstChar === letter;
|
|
});
|
|
if (game) {
|
|
setSelectedGameId(game.id);
|
|
setFocus(`GAME_${game.id}`);
|
|
}
|
|
};
|
|
|
|
const updateFocusFromScroll = (container: HTMLElement) => {
|
|
const rect = container.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
const centerY = rect.top + rect.height / 2;
|
|
|
|
const candidates = Array.from(container.querySelectorAll('[data-focusable-key]'));
|
|
if (candidates.length === 0) return;
|
|
|
|
let closestKey: string | null = null;
|
|
let minDistance = Infinity;
|
|
|
|
candidates.forEach((el) => {
|
|
const elRect = el.getBoundingClientRect();
|
|
const elCenterX = elRect.left + elRect.width / 2;
|
|
const elCenterY = elRect.top + elRect.height / 2;
|
|
|
|
const dist = Math.sqrt(Math.pow(centerX - elCenterX, 2) + Math.pow(centerY - elCenterY, 2));
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
closestKey = el.getAttribute('data-focusable-key');
|
|
}
|
|
});
|
|
|
|
if (closestKey && mode !== 'mouse' && !isCollectionModalOpen) {
|
|
console.log("[MagneticFocus] Setting focus to:", closestKey);
|
|
setFocus(closestKey);
|
|
}
|
|
};
|
|
|
|
// Right analog stick scrolling — scrolls whichever zone is currently active
|
|
const SCROLL_SPEED = 12; // Increased speed for right stick
|
|
useEffect(() => {
|
|
const onAnalogScroll = (e: Event) => {
|
|
const { dx, dy } = (e as CustomEvent<{ dx: number; dy: number }>).detail;
|
|
let target: HTMLElement | null = null;
|
|
if (activeZone === 'platforms') target = platformsRef.current as HTMLElement | null;
|
|
else if (activeZone === 'games') target = gamesRef.current as HTMLElement | null;
|
|
else if (activeZone === 'alphabet') target = alphabetRef.current as HTMLElement | null;
|
|
else if (activeZone === 'details') target = detailsRef.current as HTMLElement | null;
|
|
|
|
if (target) {
|
|
target.scrollLeft += dx * SCROLL_SPEED;
|
|
target.scrollTop += dy * SCROLL_SPEED;
|
|
|
|
// Magnetic Focus: Update the focused element to match the new scroll position
|
|
if (activeZone === 'platforms' || activeZone === 'games' || activeZone === 'alphabet') {
|
|
updateFocusFromScroll(target);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('analogscroll', onAnalogScroll);
|
|
return () => window.removeEventListener('analogscroll', onAnalogScroll);
|
|
}, [activeZone, platformsRef, gamesRef, alphabetRef, detailsRef, mode]);
|
|
|
|
// Bumper navigation — LB/RB to cycle platforms globally
|
|
useEffect(() => {
|
|
const onCycle = (direction: 'next' | 'prev') => {
|
|
if (filteredPlatforms.length <= 1) return;
|
|
|
|
// Find current platform index (use most recent selectedPlatformId)
|
|
const currentIndex = filteredPlatforms.findIndex(p => p.id === selectedPlatformId);
|
|
if (currentIndex === -1 && selectedPlatformId !== null) return;
|
|
|
|
let nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;
|
|
|
|
// Looping
|
|
if (nextIndex >= filteredPlatforms.length) nextIndex = 0;
|
|
if (nextIndex < 0) nextIndex = filteredPlatforms.length - 1;
|
|
|
|
const nextPlatform = filteredPlatforms[nextIndex];
|
|
|
|
// Silent switch: don't change activeZone highlight unless we are already there
|
|
handlePlatformFocus(nextPlatform.id, { skipZoneChange: activeZone !== 'platforms' });
|
|
|
|
// If we are currently in the platforms zone, move focus visually to the new item
|
|
if (activeZone === 'platforms') {
|
|
setFocus(`PLATFORM_${nextPlatform.id}`);
|
|
}
|
|
};
|
|
|
|
const handlePrev = () => onCycle('prev');
|
|
const handleNext = () => onCycle('next');
|
|
|
|
window.addEventListener('previousplatform', handlePrev);
|
|
window.addEventListener('nextplatform', handleNext);
|
|
return () => {
|
|
window.removeEventListener('previousplatform', handlePrev);
|
|
window.removeEventListener('nextplatform', handleNext);
|
|
};
|
|
}, [filteredPlatforms, selectedPlatformId, activeZone, mode]);
|
|
|
|
if (platformsQuery.isLoading) return <div className="h-full flex items-center justify-center text-white/50 geist-mono uppercase">Initializing Archives...</div>;
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-[#10131f] overflow-hidden">
|
|
{/* Header / Platforms */}
|
|
<div className="px-12 py-4 shrink-0 border-b border-white/5 bg-black/10">
|
|
<FocusContext.Provider value={platformsFocusKey}>
|
|
<div ref={platformsRef} className={`flex gap-6 overflow-x-auto py-4 scrollbar-hoverable ${activeZone === 'platforms' ? 'scrollbar-active' : ''}`}>
|
|
{filteredPlatforms.map(p => (
|
|
<PlatformItem
|
|
key={p.id}
|
|
platform={p}
|
|
active={selectedPlatformId === p.id}
|
|
onSelect={() => handlePlatformFocus(p.id, { skipZoneChange: false })}
|
|
/>
|
|
))}
|
|
</div>
|
|
</FocusContext.Provider>
|
|
</div>
|
|
|
|
{/* Main Split Layout */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Left Column: Game List + Alphabet */}
|
|
<div className="w-[500px] border-r border-white/5 flex overflow-hidden bg-black/20">
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
<FocusContext.Provider value={gamesFocusKey}>
|
|
<div ref={gamesRef} className={`flex-1 overflow-y-auto scrollbar-hoverable ${activeZone === 'games' ? 'scrollbar-active' : ''}`}>
|
|
{gamesQuery.isLoading ? (
|
|
<div className="p-12 text-center text-white/20 geist-mono text-[10px] uppercase">Retrieving index...</div>
|
|
) : (
|
|
gamesQuery.data?.map(game => (
|
|
<GameListItem
|
|
key={game.id}
|
|
game={game}
|
|
active={selectedGameId === game.id}
|
|
onFocus={() => {
|
|
if (mode !== 'mouse') {
|
|
setActiveZone('games');
|
|
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
|
detailTimeoutRef.current = setTimeout(() => {
|
|
setSelectedGameId(game.id);
|
|
}, 150);
|
|
} else {
|
|
setSelectedGameId(game.id);
|
|
}
|
|
}}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</FocusContext.Provider>
|
|
</div>
|
|
|
|
{activeLetters.length > 0 && (
|
|
<FocusContext.Provider value={alphabetFocusKey}>
|
|
<div ref={alphabetRef} className="h-full flex">
|
|
<AlphabetScroller
|
|
letters={activeLetters}
|
|
onLetterPress={handleLetterPress}
|
|
/>
|
|
</div>
|
|
</FocusContext.Provider>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Column: Game Details */}
|
|
<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-[40px] pl-[40px] pr-16 pb-16">
|
|
<div className="flex gap-[40px] items-start h-[480px]">
|
|
{/* Poster */}
|
|
<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 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 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 truncate whitespace-nowrap overflow-hidden">
|
|
{detailsQuery.data.title}
|
|
</h1>
|
|
|
|
{/* 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>
|
|
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
|
{detailsQuery.data.regions?.join(', ') || 'Global'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Release Date:</span>
|
|
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
|
{detailsQuery.data.releaseDate || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
{detailsQuery.data.rating && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Rating:</span>
|
|
<span className="text-[11px] font-bold text-white/60 geist-mono tracking-wider">{detailsQuery.data.rating}%</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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>
|
|
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
|
{[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].filter((val, idx, self) => self.indexOf(val) === idx).join(' / ') || 'Unknown Company'}
|
|
</span>
|
|
</div>
|
|
{(detailsQuery.data.franchises?.length ?? 0) > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Franchise:</span>
|
|
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
|
{detailsQuery.data.franchises?.join(' & ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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">
|
|
{detailsQuery.data.genres?.join(' • ') || 'No Genres'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{detailsQuery.data.esrbRating && (
|
|
<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}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 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>
|
|
)}
|
|
</FocusableItem>
|
|
|
|
<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 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>
|
|
</div>
|
|
</div>
|
|
) : selectedGameId ? (
|
|
<div className="h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="material-symbols-outlined text-4xl text-white/10 animate-spin mb-4">progress_activity</div>
|
|
<div className="text-[10px] geist-mono text-white/20 uppercase tracking-widest">Hydrating Metadata...</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center p-20 text-center opacity-20">
|
|
<div>
|
|
<span className="material-symbols-outlined text-6xl mb-6">videogame_asset</span>
|
|
<p className="geist-mono uppercase tracking-[0.3em] font-black text-sm">Target selection required</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|