a whole bunch of shit I didnt ask for
This commit is contained in:
12
src/App.tsx
12
src/App.tsx
@@ -4,6 +4,7 @@ import { TopNav } from './components/TopNav';
|
|||||||
import LibraryGrid from './components/LibraryGrid';
|
import LibraryGrid from './components/LibraryGrid';
|
||||||
import { Login } from './components/Login';
|
import { Login } from './components/Login';
|
||||||
import { GamesPage } from './components/GamesPage';
|
import { GamesPage } from './components/GamesPage';
|
||||||
|
import { CollectionsPage } from './components/CollectionsPage';
|
||||||
import { Settings } from './components/Settings';
|
import { Settings } from './components/Settings';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import { useGamepad } from './hooks/useGamepad';
|
import { useGamepad } from './hooks/useGamepad';
|
||||||
@@ -44,7 +45,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<LibraryGrid />} />
|
<Route index element={<LibraryGrid />} />
|
||||||
<Route path="games" element={<GamesPage />} />
|
<Route path="games" element={<GamesPage />}>
|
||||||
|
<Route path=":platformId" element={<GamesPage />}>
|
||||||
|
<Route path=":romId" element={<GamesPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="collections" element={<CollectionsPage />}>
|
||||||
|
<Route path=":collectionId" element={<CollectionsPage />}>
|
||||||
|
<Route path=":romId" element={<CollectionsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
90
src/components/AchievementList.tsx
Normal file
90
src/components/AchievementList.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RAAchievement, RAProgression } from '../api/client';
|
||||||
|
|
||||||
|
export const AchievementList = ({ raId, achievements, userProgression }: {
|
||||||
|
raId?: number,
|
||||||
|
achievements?: RAAchievement[],
|
||||||
|
userProgression?: RAProgression | null
|
||||||
|
}) => {
|
||||||
|
if (!raId || !achievements || achievements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="col-span-6 bg-white/5 rounded-xl p-5 border border-white/5 flex flex-col items-center justify-center opacity-40 h-[30rem]">
|
||||||
|
<span className="material-symbols-outlined text-4xl mb-3 text-white/20">trophy</span>
|
||||||
|
<div className="text-[0.625rem] geist-mono uppercase tracking-widest text-center">Achievements Unavailable</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameProgress = userProgression?.results.find(r => r.rom_ra_id === raId);
|
||||||
|
const earnedIds = new Set(gameProgress?.earned_achievements.map(a => String(a.id)) || []);
|
||||||
|
|
||||||
|
const earnedCount = gameProgress?.num_awarded || 0;
|
||||||
|
const totalCount = achievements.length;
|
||||||
|
const progressPercent = totalCount > 0 ? (earnedCount / totalCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-6 bg-white/5 rounded-xl p-5 border border-white/5 flex flex-col shadow-2xl h-[30rem] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-[#2563eb] filled">workspace_premium</span>
|
||||||
|
<h3 className="text-[0.625rem] geist-mono uppercase tracking-[0.3em] font-black text-white/60">RetroAchievements</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.625rem] geist-mono text-[#2563eb] font-bold">
|
||||||
|
{earnedCount} / {totalCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="h-1 w-full bg-white/5 rounded-full mb-6 overflow-hidden shrink-0">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[#2563eb] transition-all duration-1000 ease-out"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Achievement List */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pr-2 scrollbar-none">
|
||||||
|
{achievements.map((achievement) => {
|
||||||
|
const isEarned = earnedIds.has(String(achievement.ra_id));
|
||||||
|
const badgeId = achievement.badge_id || '00000';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={achievement.ra_id}
|
||||||
|
className={`flex items-center gap-4 p-3 rounded-lg border transition-all duration-300 ${isEarned ? 'bg-[#2563eb]/10 border-[#2563eb]/30' : 'bg-white/5 border-white/5 opacity-50'}`}
|
||||||
|
>
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="w-10 h-10 rounded-md bg-black/40 flex items-center justify-center overflow-hidden border border-white/10 shrink-0">
|
||||||
|
<img
|
||||||
|
src={`https://media.retroachievements.org/Badge/${badgeId}.png`}
|
||||||
|
className={`w-full h-full object-contain ${isEarned ? '' : 'grayscale contrast-50 opacity-40'}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h4 className={`text-[0.6875rem] font-bold uppercase truncate ${isEarned ? 'text-white' : 'text-white/40'}`}>
|
||||||
|
{achievement.title}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[0.5625rem] geist-mono text-[#2563eb] font-black shrink-0">
|
||||||
|
{achievement.points} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.5625rem] text-white/30 line-clamp-1 leading-tight mt-0.5">
|
||||||
|
{achievement.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Icon */}
|
||||||
|
{isEarned && (
|
||||||
|
<span className="material-symbols-outlined text-[#2563eb] text-sm filled shrink-0">verified</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/components/AlphabetScroller.tsx
Normal file
34
src/components/AlphabetScroller.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FocusableItem } from './FocusableItem';
|
||||||
|
|
||||||
|
export 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-[0.8125rem] font-black geist-mono transition-all duration-200
|
||||||
|
${focused ? 'text-[#2563eb] scale-125' : 'text-white/30 hover:text-white/70'}`}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
263
src/components/AtelierExplorer.tsx
Normal file
263
src/components/AtelierExplorer.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
|
import { useInputMode } from '../context/InputModeContext';
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AtelierExplorerProps<C extends Category, I extends { id: string }> {
|
||||||
|
categoryName: string; // e.g., "PLATFORM" or "COLLECTION"
|
||||||
|
categoryRoute: string; // e.g., "games" or "collections"
|
||||||
|
categories: C[];
|
||||||
|
items: I[];
|
||||||
|
isLoadingCategories: boolean;
|
||||||
|
isLoadingItems: boolean;
|
||||||
|
|
||||||
|
renderCategory: (category: C, active: boolean, onSelect: () => void) => React.ReactNode;
|
||||||
|
renderListItem: (item: I, active: boolean, onFocus: () => void) => React.ReactNode;
|
||||||
|
renderDetails: (itemId: string, activeZone: string, setActiveZone: (z: string) => void) => React.ReactNode;
|
||||||
|
|
||||||
|
// Modals / Overlays
|
||||||
|
overlays?: React.ReactNode;
|
||||||
|
|
||||||
|
// Optional alphabet scroller
|
||||||
|
alphabet?: React.ReactNode;
|
||||||
|
|
||||||
|
// Event overrides
|
||||||
|
onBumperCycle?: (direction: 'next' | 'prev') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AtelierExplorer = <C extends Category, I extends { id: string }>({
|
||||||
|
categoryName,
|
||||||
|
categoryRoute,
|
||||||
|
categories,
|
||||||
|
items,
|
||||||
|
isLoadingCategories,
|
||||||
|
isLoadingItems,
|
||||||
|
renderCategory,
|
||||||
|
renderListItem,
|
||||||
|
renderDetails,
|
||||||
|
overlays,
|
||||||
|
alphabet,
|
||||||
|
onBumperCycle
|
||||||
|
}: AtelierExplorerProps<C, I>) => {
|
||||||
|
const { mode } = useInputMode();
|
||||||
|
const { platformId, collectionId, romId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Use either platformId or collectionId based on route
|
||||||
|
const activeCategoryId = platformId || collectionId;
|
||||||
|
|
||||||
|
const [activeZone, setActiveZone] = useState('games');
|
||||||
|
const detailTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Refs for scrolling
|
||||||
|
const categoriesRef = useRef<HTMLDivElement>(null);
|
||||||
|
const itemsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const alphabetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const detailsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Spatial Nav Keys
|
||||||
|
const { ref: categoriesRefNav, focusKey: categoriesFocusKey } = useFocusable();
|
||||||
|
const { ref: itemsRefNav, focusKey: itemsFocusKey } = useFocusable();
|
||||||
|
const { ref: alphabetRefNav, focusKey: alphabetFocusKey } = useFocusable();
|
||||||
|
const { ref: detailsRefNav, focusKey: detailsFocusKey } = useFocusable();
|
||||||
|
|
||||||
|
// URL -> State Sync (Focus Only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'mouse') {
|
||||||
|
if (romId) {
|
||||||
|
setFocus(`ITEM_${romId}`);
|
||||||
|
} else if (activeCategoryId) {
|
||||||
|
setFocus(`${categoryName}_${activeCategoryId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeCategoryId, romId, mode, categoryName]);
|
||||||
|
|
||||||
|
// Initial Auto-select
|
||||||
|
useEffect(() => {
|
||||||
|
if (categories.length > 0 && !activeCategoryId) {
|
||||||
|
navigate(`/${categoryRoute}/${categories[0].id}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [categories, activeCategoryId, navigate, categoryRoute]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'mouse' && items.length > 0 && !romId && activeCategoryId) {
|
||||||
|
navigate(`/${categoryRoute}/${activeCategoryId}/${items[0].id}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [items, romId, activeCategoryId, mode, navigate, categoryRoute]);
|
||||||
|
|
||||||
|
const handleCategoryFocus = (id: string | number, options: { skipZoneChange?: boolean } = {}) => {
|
||||||
|
if (mode !== 'mouse') {
|
||||||
|
if (!options.skipZoneChange) setActiveZone('categories');
|
||||||
|
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
||||||
|
detailTimeoutRef.current = setTimeout(() => {
|
||||||
|
navigate(`/${categoryRoute}/${id}`, { replace: true });
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
navigate(`/${categoryRoute}/${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') {
|
||||||
|
setFocus(closestKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Right analog stick scrolling
|
||||||
|
const SCROLL_SPEED = 12;
|
||||||
|
useEffect(() => {
|
||||||
|
const onAnalogScroll = (e: Event) => {
|
||||||
|
const { dx, dy } = (e as CustomEvent<{ dx: number; dy: number }>).detail;
|
||||||
|
let target: HTMLElement | null = null;
|
||||||
|
if (activeZone === 'categories') target = categoriesRef.current;
|
||||||
|
else if (activeZone === 'games') target = itemsRef.current;
|
||||||
|
else if (activeZone === 'alphabet') target = alphabetRef.current;
|
||||||
|
else if (activeZone === 'details') target = detailsRef.current;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.scrollLeft += dx * SCROLL_SPEED;
|
||||||
|
target.scrollTop += dy * SCROLL_SPEED;
|
||||||
|
|
||||||
|
if (activeZone !== 'details') {
|
||||||
|
updateFocusFromScroll(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('analogscroll', onAnalogScroll);
|
||||||
|
return () => window.removeEventListener('analogscroll', onAnalogScroll);
|
||||||
|
}, [activeZone]);
|
||||||
|
|
||||||
|
// Bumper Navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const onCycle = (direction: 'next' | 'prev') => {
|
||||||
|
if (onBumperCycle) {
|
||||||
|
onBumperCycle(direction);
|
||||||
|
} else {
|
||||||
|
if (categories.length <= 1) return;
|
||||||
|
const currentIndex = categories.findIndex(c => String(c.id) === String(activeCategoryId));
|
||||||
|
if (currentIndex === -1 && activeCategoryId) return;
|
||||||
|
|
||||||
|
let nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;
|
||||||
|
if (nextIndex >= categories.length) nextIndex = 0;
|
||||||
|
if (nextIndex < 0) nextIndex = categories.length - 1;
|
||||||
|
|
||||||
|
const nextCat = categories[nextIndex];
|
||||||
|
handleCategoryFocus(nextCat.id, { skipZoneChange: activeZone !== 'categories' });
|
||||||
|
if (activeZone === 'categories') {
|
||||||
|
setFocus(`${categoryName}_${nextCat.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);
|
||||||
|
};
|
||||||
|
}, [categories, activeCategoryId, activeZone, mode, onBumperCycle]);
|
||||||
|
|
||||||
|
if (isLoadingCategories) 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 / Categories */}
|
||||||
|
<div className="px-12 py-4 shrink-0 border-b border-white/5 bg-black/10">
|
||||||
|
<FocusContext.Provider value={categoriesFocusKey}>
|
||||||
|
<div ref={categoriesRefNav}>
|
||||||
|
<div ref={categoriesRef} className={`flex gap-6 overflow-x-auto py-4 scrollbar-hoverable ${activeZone === 'categories' ? 'scrollbar-active' : ''}`}>
|
||||||
|
{categories.map(c => renderCategory(c, String(activeCategoryId) === String(c.id), () => handleCategoryFocus(c.id, { skipZoneChange: false })))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusContext.Provider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Split Layout */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Left Column: List + Alphabet */}
|
||||||
|
<div className="w-[31.25rem] border-r border-white/5 flex overflow-hidden bg-black/20">
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<FocusContext.Provider value={itemsFocusKey}>
|
||||||
|
<div ref={itemsRefNav} className="flex-1 overflow-hidden">
|
||||||
|
<div ref={itemsRef} className={`h-full overflow-y-auto scrollbar-hoverable ${activeZone === 'games' ? 'scrollbar-active' : ''}`}>
|
||||||
|
{isLoadingItems ? (
|
||||||
|
<div className="p-12 text-center text-white/20 geist-mono text-[0.625rem] uppercase tracking-widest">Retrieving index...</div>
|
||||||
|
) : (
|
||||||
|
items.map(item => renderListItem(item, romId === item.id, () => {
|
||||||
|
if (mode !== 'mouse') {
|
||||||
|
setActiveZone('games');
|
||||||
|
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
||||||
|
detailTimeoutRef.current = setTimeout(() => {
|
||||||
|
navigate(`/${categoryRoute}/${activeCategoryId}/${item.id}`, { replace: true });
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
navigate(`/${categoryRoute}/${activeCategoryId}/${item.id}`);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusContext.Provider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alphabet && (
|
||||||
|
<FocusContext.Provider value={alphabetFocusKey}>
|
||||||
|
<div ref={alphabetRefNav}>
|
||||||
|
<div ref={alphabetRef} className="h-full flex">
|
||||||
|
{alphabet}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusContext.Provider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Details */}
|
||||||
|
<FocusContext.Provider value={detailsFocusKey}>
|
||||||
|
<div ref={detailsRefNav} className="flex-1 overflow-hidden">
|
||||||
|
<div ref={detailsRef} className={`h-full overflow-y-auto relative scrollbar-hoverable ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
||||||
|
{romId ? renderDetails(romId, activeZone, setActiveZone) : (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</FocusContext.Provider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overlays}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/components/CollectionHeaderItem.tsx
Normal file
39
src/components/CollectionHeaderItem.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RommCollection } from '../api/client';
|
||||||
|
import { FocusableItem } from './FocusableItem';
|
||||||
|
|
||||||
|
export const CollectionHeaderItem = ({ collection, active, onSelect }: { collection: RommCollection, active: boolean, onSelect: () => void }) => (
|
||||||
|
<FocusableItem
|
||||||
|
onFocus={onSelect}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="shrink-0"
|
||||||
|
focusKey={`COLLECTION_${collection.id}`}
|
||||||
|
active={active}
|
||||||
|
>
|
||||||
|
{(focused) => (
|
||||||
|
<div className="flex flex-col items-center gap-2 group cursor-pointer w-[6.875rem]">
|
||||||
|
<div className={`w-[5.25rem] h-[5.25rem] rounded-xl transition-all duration-500 border-2 flex items-center justify-center p-3 overflow-hidden
|
||||||
|
${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'}`}
|
||||||
|
>
|
||||||
|
{collection.coverUrl ? (
|
||||||
|
<img
|
||||||
|
src={collection.coverUrl}
|
||||||
|
alt={collection.name}
|
||||||
|
className={`w-full h-full object-cover transition-all duration-500 ${focused || active ? 'scale-110 brightness-100' : 'grayscale brightness-150 opacity-30 group-hover:opacity-60'}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={`material-symbols-outlined text-3xl transition-colors ${focused || active ? 'text-white' : 'text-white/20'}`}>
|
||||||
|
{collection.is_favorite ? 'star' : 'library_books'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-[0.5625rem] 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'}`}
|
||||||
|
>
|
||||||
|
{collection.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
);
|
||||||
122
src/components/CollectionsPage.tsx
Normal file
122
src/components/CollectionsPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { GameListItem } from './GameListItem';
|
||||||
|
import { GameDetailsPane } from './GameDetailsPane';
|
||||||
|
import { AlphabetScroller } from './AlphabetScroller';
|
||||||
|
import { AtelierExplorer } from './AtelierExplorer';
|
||||||
|
import { CollectionHeaderItem } from './CollectionHeaderItem';
|
||||||
|
import { CollectionModal } from './CollectionModal';
|
||||||
|
import { ManualModal } from './ManualModal';
|
||||||
|
import { EmulatorOverlay } from './EmulatorOverlay';
|
||||||
|
import { rommApiClient } from '../api/client';
|
||||||
|
import { useInputMode } from '../context/InputModeContext';
|
||||||
|
|
||||||
|
export const CollectionsPage = () => {
|
||||||
|
const { mode } = useInputMode();
|
||||||
|
const { collectionId, romId } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [activeMediaIndex, setActiveMediaIndex] = useState(0);
|
||||||
|
const [playingGameId, setPlayingGameId] = useState<string | null>(null);
|
||||||
|
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
||||||
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const collectionsQuery = useQuery({
|
||||||
|
queryKey: ['collections'],
|
||||||
|
queryFn: () => rommApiClient.fetchCollections()
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCollectionId = collectionId || '';
|
||||||
|
const activeCollection = collectionsQuery.data?.find(c => String(c.id) === String(activeCollectionId));
|
||||||
|
|
||||||
|
const detailsQuery = useQuery({
|
||||||
|
queryKey: ['gameDetails', romId],
|
||||||
|
queryFn: () => rommApiClient.fetchGameDetails(romId!),
|
||||||
|
enabled: !!romId,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userQuery = useQuery({
|
||||||
|
queryKey: ['currentUser'],
|
||||||
|
queryFn: () => rommApiClient.fetchCurrentUser()
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoriteMutation = useMutation({
|
||||||
|
mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) =>
|
||||||
|
rommApiClient.toggleFavorite(gameId, favorite),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gameDetails', romId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeLetters = useMemo(() => {
|
||||||
|
if (!activeCollection?.games) return [];
|
||||||
|
const letters = new Set<string>();
|
||||||
|
activeCollection.games.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();
|
||||||
|
}, [activeCollection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AtelierExplorer
|
||||||
|
categoryName="COLLECTION"
|
||||||
|
categoryRoute="collections"
|
||||||
|
categories={(collectionsQuery.data || []).map(c => ({ id: c.id, name: c.name }))}
|
||||||
|
items={activeCollection?.games || []}
|
||||||
|
isLoadingCategories={collectionsQuery.isLoading}
|
||||||
|
isLoadingItems={collectionsQuery.isLoading}
|
||||||
|
renderCategory={(c, active, onSelect) => {
|
||||||
|
const fullCol = collectionsQuery.data?.find(col => String(col.id) === String(c.id));
|
||||||
|
if (!fullCol) return null;
|
||||||
|
return <CollectionHeaderItem key={c.id} collection={fullCol} active={active} onSelect={onSelect} />;
|
||||||
|
}}
|
||||||
|
renderListItem={(game, active, onFocus) => (
|
||||||
|
<GameListItem key={game.id} game={game} active={active} onFocus={onFocus} />
|
||||||
|
)}
|
||||||
|
alphabet={activeLetters.length > 0 && (
|
||||||
|
<AlphabetScroller letters={activeLetters} onLetterPress={() => {}} />
|
||||||
|
)}
|
||||||
|
renderDetails={(itemId, activeZone, setActiveZone) => (
|
||||||
|
detailsQuery.data ? (
|
||||||
|
<GameDetailsPane
|
||||||
|
game={detailsQuery.data}
|
||||||
|
itemId={itemId}
|
||||||
|
user={userQuery.data}
|
||||||
|
activeMediaIndex={activeMediaIndex}
|
||||||
|
setActiveMediaIndex={setActiveMediaIndex}
|
||||||
|
activeZone={activeZone}
|
||||||
|
setActiveZone={setActiveZone}
|
||||||
|
onPlay={setPlayingGameId}
|
||||||
|
onFavorite={(id, fav) => favoriteMutation.mutate({ gameId: id, favorite: fav })}
|
||||||
|
onOpenCollection={() => setIsCollectionModalOpen(true)}
|
||||||
|
onOpenManual={() => setIsManualModalOpen(true)}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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-[0.625rem] geist-mono text-white/20 uppercase tracking-widest">Hydrating Metadata...</div></div></div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
overlays={
|
||||||
|
<>
|
||||||
|
{isCollectionModalOpen && detailsQuery.data && (
|
||||||
|
<CollectionModal
|
||||||
|
collections={collectionsQuery.data || []}
|
||||||
|
onClose={() => setIsCollectionModalOpen(false)}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onCreate={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{playingGameId && <EmulatorOverlay gameId={playingGameId} onClose={() => setPlayingGameId(null)} />}
|
||||||
|
{isManualModalOpen && detailsQuery.data && (
|
||||||
|
<ManualModal gameId={detailsQuery.data.id} platformId={detailsQuery.data.platformId} onClose={() => setIsManualModalOpen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/FocusableItem.tsx
Normal file
48
src/components/FocusableItem.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
|
import { useInputMode } from '../context/InputModeContext';
|
||||||
|
|
||||||
|
export interface FocusableItemProps {
|
||||||
|
onFocus: () => void;
|
||||||
|
onEnterPress?: () => void;
|
||||||
|
children: (focused: boolean) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
focusKey?: string;
|
||||||
|
scrollOptions?: ScrollIntoViewOptions;
|
||||||
|
active?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
src/components/GameDetailsPane.tsx
Normal file
135
src/components/GameDetailsPane.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DetailedGame, RAProgression, UserProfile } from '../api/client';
|
||||||
|
import { FocusableItem } from './FocusableItem';
|
||||||
|
import { AchievementList } from './AchievementList';
|
||||||
|
|
||||||
|
interface GameDetailsPaneProps {
|
||||||
|
game: DetailedGame;
|
||||||
|
itemId: string;
|
||||||
|
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;
|
||||||
|
onOpenCollection: () => void;
|
||||||
|
onOpenManual: () => void;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GameDetailsPane = ({
|
||||||
|
game,
|
||||||
|
itemId,
|
||||||
|
user,
|
||||||
|
activeMediaIndex,
|
||||||
|
setActiveMediaIndex,
|
||||||
|
activeZone,
|
||||||
|
setActiveZone,
|
||||||
|
onPlay,
|
||||||
|
onFavorite,
|
||||||
|
onOpenCollection,
|
||||||
|
onOpenManual,
|
||||||
|
mode
|
||||||
|
}: GameDetailsPaneProps) => {
|
||||||
|
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 });
|
||||||
|
if (game.screenshots) {
|
||||||
|
game.screenshots.forEach(s => mediaItems.push({ type: 'image', url: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFocusZone = () => { if (mode === 'gamepad') setActiveZone('details'); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-10 pl-10 pr-16 pb-16">
|
||||||
|
<div className="flex gap-10 items-start h-[30rem]">
|
||||||
|
{/* Poster */}
|
||||||
|
<div className="w-[20rem] h-full rounded-2xl overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 relative group">
|
||||||
|
<img src={game.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>
|
||||||
|
|
||||||
|
{/* Metadata Content */}
|
||||||
|
<div className="flex-1 flex flex-col h-full min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-0 uppercase geist-mono font-black text-[0.625rem] tracking-widest">
|
||||||
|
<div className="bg-[#2563eb] text-white px-3 py-1 rounded shadow-lg shadow-[#2563eb]/20">Playable</div>
|
||||||
|
<div className="bg-white/10 text-white/60 px-3 py-1 rounded border border-white/5">{game.players || '1 Player'}</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[3.375rem] font-black text-white leading-[0.9] tracking-tighter uppercase mb-3 geist-mono truncate" title={game.title}>{game.title}</h1>
|
||||||
|
<div className="border-t border-white/10 w-full mb-3"></div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4 text-[0.6875rem] font-black tracking-[0.2em] geist-mono uppercase">
|
||||||
|
<div className="flex items-center gap-10 overflow-hidden">
|
||||||
|
<div className="flex gap-2 min-w-0 shrink-0"><span className="text-[#2563eb] w-[8.75rem] shrink-0">Region:</span> <span className="text-white truncate max-w-[12.5rem]">{game.regions?.join(', ') || 'N/A'}</span></div>
|
||||||
|
<div className="flex gap-2 shrink-0"><span className="text-[#2563eb] shrink-0">Release date:</span> <span className="text-white">{game.releaseDate || 'N/A'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-10 overflow-hidden">
|
||||||
|
<div className="flex gap-2 min-w-0 shrink-0"><span className="text-[#2563eb] w-[8.75rem] shrink-0">Franchise:</span> <span className="text-white truncate max-w-[12.5rem]">{game.collections?.join(', ') || 'N/A'}</span></div>
|
||||||
|
<div className="flex gap-2 min-w-0"><span className="text-[#2563eb] shrink-0">Companies:</span> <span className="text-white truncate font-bold text-ellipsis">{[...(game.developers || []), ...(game.publishers || [])].slice(0, 3).join(', ') || 'Unknown Company'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-10 overflow-hidden">
|
||||||
|
<div className="flex gap-2 min-w-0 shrink-0"><span className="text-[#2563eb] w-[8.75rem] shrink-0">Age Rating:</span> <span className="text-white">{game.esrbRating || 'NR'}</span></div>
|
||||||
|
<div className="flex gap-2 min-w-0"><span className="text-[#2563eb] shrink-0">Genres:</span> <span className="text-white truncate">{game.genres?.slice(0, 4).join(', ') || 'N/A'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-5 mb-8 flex-1 overflow-y-auto border border-white/5 scrollbar-hide">
|
||||||
|
<p className="text-[0.9375rem] text-white/80 leading-[1.8] font-medium">{game.summary || 'No summary available for this entry.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FocusableItem onFocus={setFocusZone} onEnterPress={() => onPlay(itemId)} onClick={() => onPlay(itemId)} className="shrink-0" focusKey="DETAILS_PLAY">
|
||||||
|
{(focused) => (<button className={`h-[3.375rem] px-8 rounded-xl font-black uppercase text-[0.75rem] tracking-[0.1em] flex items-center gap-3 transition-all duration-300 shadow-2xl ${focused ? 'bg-white text-black scale-110 ring-4 ring-[#2563eb] shadow-[0_0_25px_rgba(37,99,235,0.4)]' : 'bg-white text-black hover:scale-105'}`}><span className="material-symbols-outlined text-[1.5rem] filled" style={{ fontVariationSettings: "'FILL' 1" }}>play_arrow</span>Start Game</button>)}
|
||||||
|
</FocusableItem>
|
||||||
|
<FocusableItem onFocus={setFocusZone} className="shrink-0" focusKey="DETAILS_DOWNLOAD">
|
||||||
|
{(focused) => (<button className={`h-[3.375rem] px-8 rounded-xl font-black uppercase text-[0.75rem] tracking-[0.1em] flex items-center gap-3 transition-all duration-300 border-2 ${focused ? 'scale-110 border-[#2563eb] bg-[#2563eb]/20 text-white ring-4 ring-[#2563eb]/20' : 'bg-white/5 border-white/10 text-white'}`}><span className="material-symbols-outlined text-[1.5rem]">download</span>Download</button>)}
|
||||||
|
</FocusableItem>
|
||||||
|
<div className="flex gap-3 ml-4">
|
||||||
|
<FocusableItem onFocus={setFocusZone} onEnterPress={() => onFavorite(itemId, !game.favorite)} onClick={() => onFavorite(itemId, !game.favorite)} focusKey="DETAILS_FAVORITE">
|
||||||
|
{(focused) => (
|
||||||
|
<button className={`w-[3.375rem] h-[3.375rem] flex items-center justify-center transition-all duration-300 rounded-xl border-2 ${focused ? 'scale-110 border-[#2563eb] bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10'}`}>
|
||||||
|
<span className={`material-symbols-outlined text-[1.5rem] ${game.favorite ? 'filled text-[#2563eb]' : 'text-white/40'} ${focused ? '!text-white' : ''}`} style={game.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}>star</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
<FocusableItem onFocus={setFocusZone} onEnterPress={onOpenCollection} onClick={onOpenCollection} focusKey="DETAILS_COLLECTION">
|
||||||
|
{(focused) => (<button className={`w-[3.375rem] h-[3.375rem] flex items-center justify-center transition-all duration-300 rounded-xl border-2 ${focused ? 'scale-110 border-[#2563eb] text-white bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white/40'}`}><span className="material-symbols-outlined text-[1.5rem]">library_add</span></button>)}
|
||||||
|
</FocusableItem>
|
||||||
|
<FocusableItem onFocus={setFocusZone} onEnterPress={onOpenManual} onClick={onOpenManual} focusKey="DETAILS_MANUAL">
|
||||||
|
{(focused) => (<button className={`w-[3.375rem] h-[3.375rem] flex items-center justify-center transition-all duration-300 rounded-xl border-2 ${focused ? 'scale-110 border-[#2563eb] text-white bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white/40'}`}><span className="material-symbols-outlined text-[1.5rem]">menu_book</span></button>)}
|
||||||
|
</FocusableItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media Galleria Container */}
|
||||||
|
{mediaItems.length > 0 && (
|
||||||
|
<div className="mt-12 grid grid-cols-12 gap-8 items-stretch">
|
||||||
|
<div className="col-span-6 bg-white/5 rounded-xl p-5 border border-white/5 flex flex-col shadow-2xl h-[30rem] overflow-hidden">
|
||||||
|
<div className="aspect-video w-full rounded-[0.625rem] bg-black/40 border border-white/10 overflow-hidden relative mb-5 group">
|
||||||
|
{mediaItems[activeMediaIndex].type === 'video' ? (
|
||||||
|
mediaItems[activeMediaIndex].youtubeId ? (
|
||||||
|
<iframe src={`https://www.youtube.com/embed/${mediaItems[activeMediaIndex].youtubeId}?autoplay=0&controls=1&rel=0`} className="w-full h-full" allowFullScreen></iframe>
|
||||||
|
) : (<video key={mediaItems[activeMediaIndex].url} src={mediaItems[activeMediaIndex].url} controls className="w-full h-full object-cover" />)
|
||||||
|
) : (<img src={mediaItems[activeMediaIndex].url} className="w-full h-full object-cover animate-fade-in" alt="" />)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
|
||||||
|
{mediaItems.map((item, idx) => (
|
||||||
|
<FocusableItem key={idx} onFocus={() => { setFocusZone(); setActiveMediaIndex(idx); }} onClick={() => setActiveMediaIndex(idx)} className="shrink-0" focusKey={`THUMB_${idx}`}>
|
||||||
|
{(focused) => (
|
||||||
|
<div className={`w-[7.75rem] aspect-video rounded-md border-2 transition-all duration-300 overflow-hidden relative ${focused || activeMediaIndex === idx ? 'border-[#2563eb] scale-105 z-10' : 'border-white/10 opacity-40 grayscale hover:opacity-100 hover:grayscale-0'}`}>
|
||||||
|
{item.type === 'video' ? (<div className="w-full h-full bg-black/60 flex items-center justify-center"><span className="material-symbols-outlined text-[#2563eb] text-3xl filled">play_circle</span></div>) : (<img src={item.url} className="w-full h-full object-cover" alt="" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AchievementList raId={game.ra_id} achievements={game.merged_ra_metadata?.achievements} userProgression={user?.ra_progression} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
src/components/GameListItem.tsx
Normal file
32
src/components/GameListItem.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Game } from '../api/client';
|
||||||
|
import { FocusableItem } from './FocusableItem';
|
||||||
|
|
||||||
|
export 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 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-[0.625rem] text-white/40 font-medium geist-mono uppercase tracking-tight">
|
||||||
|
{game.size}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
);
|
||||||
@@ -1,306 +1,50 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
import { GameListItem } from './GameListItem';
|
||||||
|
import { GameDetailsPane } from './GameDetailsPane';
|
||||||
|
import { AlphabetScroller } from './AlphabetScroller';
|
||||||
|
import { AtelierExplorer } from './AtelierExplorer';
|
||||||
|
import { PlatformItem } from './PlatformItem';
|
||||||
import { CollectionModal } from './CollectionModal';
|
import { CollectionModal } from './CollectionModal';
|
||||||
import { ManualModal } from './ManualModal';
|
import { ManualModal } from './ManualModal';
|
||||||
import { rommApiClient, Platform, Game, RAAchievement, RAProgression } from '../api/client';
|
|
||||||
import { useInputMode } from '../context/InputModeContext';
|
|
||||||
import { EmulatorOverlay } from './EmulatorOverlay';
|
import { EmulatorOverlay } from './EmulatorOverlay';
|
||||||
|
import { rommApiClient } from '../api/client';
|
||||||
interface FocusableItemProps {
|
import { useInputMode } from '../context/InputModeContext';
|
||||||
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 AchievementList = ({ raId, achievements, userProgression }: {
|
|
||||||
raId?: number,
|
|
||||||
achievements?: RAAchievement[],
|
|
||||||
userProgression?: RAProgression | null
|
|
||||||
}) => {
|
|
||||||
if (!raId || !achievements || achievements.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="col-span-6 bg-white/5 rounded-xl p-5 border border-white/5 flex flex-col items-center justify-center opacity-40 h-[30rem]">
|
|
||||||
<span className="material-symbols-outlined text-4xl mb-3 text-white/20">trophy</span>
|
|
||||||
<div className="text-[0.625rem] geist-mono uppercase tracking-widest text-center">Achievements Unavailable</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gameProgress = userProgression?.results.find(r => r.rom_ra_id === raId);
|
|
||||||
const earnedIds = new Set(gameProgress?.earned_achievements.map(a => String(a.id)) || []);
|
|
||||||
|
|
||||||
const earnedCount = gameProgress?.num_awarded || 0;
|
|
||||||
const totalCount = achievements.length;
|
|
||||||
const progressPercent = totalCount > 0 ? (earnedCount / totalCount) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="col-span-6 bg-white/5 rounded-xl p-5 border border-white/5 flex flex-col shadow-2xl h-[30rem] overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="material-symbols-outlined text-[#2563eb] filled">workspace_premium</span>
|
|
||||||
<h3 className="text-[0.625rem] geist-mono uppercase tracking-[0.3em] font-black text-white/60">RetroAchievements</h3>
|
|
||||||
</div>
|
|
||||||
<div className="text-[0.625rem] geist-mono text-[#2563eb] font-bold">
|
|
||||||
{earnedCount} / {totalCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="h-1 w-full bg-white/5 rounded-full mb-6 overflow-hidden shrink-0">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[#2563eb] transition-all duration-1000 ease-out"
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Achievement List */}
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2 scrollbar-none">
|
|
||||||
{achievements.map((achievement) => {
|
|
||||||
const isEarned = earnedIds.has(String(achievement.ra_id));
|
|
||||||
const badgeId = achievement.badge_id || '00000';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={achievement.ra_id}
|
|
||||||
className={`flex items-center gap-4 p-3 rounded-lg border transition-all duration-300 ${isEarned ? 'bg-[#2563eb]/10 border-[#2563eb]/30' : 'bg-white/5 border-white/5 opacity-50'}`}
|
|
||||||
>
|
|
||||||
{/* Badge */}
|
|
||||||
<div className="w-10 h-10 rounded-md bg-black/40 flex items-center justify-center overflow-hidden border border-white/10 shrink-0">
|
|
||||||
<img
|
|
||||||
src={`https://media.retroachievements.org/Badge/${badgeId}.png`}
|
|
||||||
className={`w-full h-full object-contain ${isEarned ? '' : 'grayscale contrast-50 opacity-40'}`}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h4 className={`text-[0.6875rem] font-bold uppercase truncate ${isEarned ? 'text-white' : 'text-white/40'}`}>
|
|
||||||
{achievement.title}
|
|
||||||
</h4>
|
|
||||||
<span className="text-[0.5625rem] geist-mono text-[#2563eb] font-black shrink-0">
|
|
||||||
{achievement.points} pts
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[0.5625rem] text-white/30 line-clamp-1 leading-tight mt-0.5">
|
|
||||||
{achievement.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Icon */}
|
|
||||||
{isEarned && (
|
|
||||||
<span className="material-symbols-outlined text-[#2563eb] text-sm filled shrink-0">verified</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</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-[6.875rem]">
|
|
||||||
<div className={`w-[5.25rem] h-[5.25rem] rounded-xl 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-[0.5625rem] 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 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-[0.625rem] 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-[0.8125rem] 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 = () => {
|
export const GamesPage = () => {
|
||||||
const { mode } = useInputMode();
|
const { mode } = useInputMode();
|
||||||
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
const { platformId, romId } = useParams();
|
||||||
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [activeMediaIndex, setActiveMediaIndex] = useState(0);
|
||||||
const [playingGameId, setPlayingGameId] = useState<string | null>(null);
|
const [playingGameId, setPlayingGameId] = useState<string | null>(null);
|
||||||
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
const [activeMediaIndex, setActiveMediaIndex] = useState(0);
|
|
||||||
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
|
|
||||||
const detailTimeoutRef = React.useRef<any>(null);
|
|
||||||
|
|
||||||
const userQuery = useQuery({
|
|
||||||
queryKey: ['userProfile'],
|
|
||||||
queryFn: () => rommApiClient.fetchCurrentUser(),
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes cache
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
const platformsQuery = useQuery({
|
||||||
queryKey: ['platforms'],
|
queryKey: ['platforms'],
|
||||||
queryFn: () => rommApiClient.fetchPlatforms()
|
queryFn: () => rommApiClient.fetchPlatforms()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedPlatformId = platformId ? parseInt(platformId) : null;
|
||||||
|
|
||||||
const gamesQuery = useQuery({
|
const gamesQuery = useQuery({
|
||||||
queryKey: ['games', selectedPlatformId],
|
queryKey: ['platformGames', selectedPlatformId],
|
||||||
queryFn: () => selectedPlatformId ? rommApiClient.fetchGamesByPlatform(selectedPlatformId) : Promise.resolve([]),
|
queryFn: () => rommApiClient.fetchGamesByPlatform(selectedPlatformId!),
|
||||||
enabled: !!selectedPlatformId
|
enabled: !!selectedPlatformId
|
||||||
});
|
});
|
||||||
|
|
||||||
const detailsQuery = useQuery({
|
const detailsQuery = useQuery({
|
||||||
queryKey: ['gameDetails', selectedGameId],
|
queryKey: ['gameDetails', romId],
|
||||||
queryFn: () => selectedGameId ? rommApiClient.fetchGameDetails(selectedGameId) : Promise.resolve(null),
|
queryFn: () => rommApiClient.fetchGameDetails(romId!),
|
||||||
enabled: !!selectedGameId
|
enabled: !!romId,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const userQuery = useQuery({
|
||||||
const favoriteMutation = useMutation({
|
queryKey: ['currentUser'],
|
||||||
mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) =>
|
queryFn: () => rommApiClient.fetchCurrentUser()
|
||||||
rommApiClient.toggleFavorite(gameId, favorite),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const collectionsQuery = useQuery({
|
const collectionsQuery = useQuery({
|
||||||
@@ -308,598 +52,79 @@ export const GamesPage = () => {
|
|||||||
queryFn: () => rommApiClient.fetchCollections()
|
queryFn: () => rommApiClient.fetchCollections()
|
||||||
});
|
});
|
||||||
|
|
||||||
const addToCollectionMutation = useMutation({
|
const favoriteMutation = useMutation({
|
||||||
mutationFn: async ({ collectionId, gameId, name }: { collectionId: string, gameId: string, name: string }) => {
|
mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) =>
|
||||||
const details = await rommApiClient.fetchCollectionDetails(collectionId);
|
rommApiClient.toggleFavorite(gameId, favorite),
|
||||||
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: () => {
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gameDetails', romId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
|
||||||
setIsCollectionModalOpen(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredPlatforms = React.useMemo(() =>
|
const activeLetters = 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 [];
|
if (!gamesQuery.data) return [];
|
||||||
const letters = new Set<string>();
|
const letters = new Set<string>();
|
||||||
gamesQuery.data.forEach(game => {
|
gamesQuery.data.forEach(game => {
|
||||||
const firstChar = (game.title || '').charAt(0).toUpperCase();
|
const firstChar = (game.title || '').charAt(0).toUpperCase();
|
||||||
if (/[A-Z]/.test(firstChar)) {
|
if (/[A-Z]/.test(firstChar)) letters.add(firstChar);
|
||||||
letters.add(firstChar);
|
else letters.add('#');
|
||||||
} else {
|
|
||||||
letters.add('#');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return Array.from(letters).sort();
|
return Array.from(letters).sort();
|
||||||
}, [gamesQuery.data]);
|
}, [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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Consolidate media for Galleria
|
|
||||||
const mediaItems = React.useMemo(() => {
|
|
||||||
if (!detailsQuery.data) return [];
|
|
||||||
const items: { type: 'video' | 'image', url: string, youtubeId?: string }[] = [];
|
|
||||||
|
|
||||||
if (detailsQuery.data.youtubeId) {
|
|
||||||
items.push({ type: 'video', url: '', youtubeId: detailsQuery.data.youtubeId });
|
|
||||||
} else if (detailsQuery.data.videoUrl) {
|
|
||||||
items.push({ type: 'video', url: detailsQuery.data.videoUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detailsQuery.data.screenshots) {
|
|
||||||
detailsQuery.data.screenshots.forEach(s => {
|
|
||||||
items.push({ type: 'image', url: s });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [detailsQuery.data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveMediaIndex(0);
|
|
||||||
}, [selectedGameId]);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="h-full flex flex-col bg-[#10131f] overflow-hidden">
|
<AtelierExplorer
|
||||||
{/* Header / Platforms */}
|
categoryName="PLATFORM"
|
||||||
<div className="px-12 py-4 shrink-0 border-b border-white/5 bg-black/10">
|
categoryRoute="games"
|
||||||
<FocusContext.Provider value={platformsFocusKey}>
|
categories={(platformsQuery.data || []).filter(p => p.romCount > 0)}
|
||||||
<div ref={platformsRef} className={`flex gap-6 overflow-x-auto py-4 scrollbar-hoverable ${activeZone === 'platforms' ? 'scrollbar-active' : ''}`}>
|
items={gamesQuery.data || []}
|
||||||
{filteredPlatforms.map(p => (
|
isLoadingCategories={platformsQuery.isLoading}
|
||||||
<PlatformItem
|
isLoadingItems={gamesQuery.isLoading}
|
||||||
key={p.id}
|
renderCategory={(p, active, onSelect) => (
|
||||||
platform={p}
|
<PlatformItem key={p.id} platform={p} active={active} onSelect={onSelect} />
|
||||||
active={selectedPlatformId === p.id}
|
)}
|
||||||
onSelect={() => handlePlatformFocus(p.id, { skipZoneChange: false })}
|
renderListItem={(game, active, onFocus) => (
|
||||||
/>
|
<GameListItem key={game.id} game={game} active={active} onFocus={onFocus} />
|
||||||
))}
|
)}
|
||||||
</div>
|
alphabet={activeLetters.length > 0 && (
|
||||||
</FocusContext.Provider>
|
<AlphabetScroller letters={activeLetters} onLetterPress={() => {}} />
|
||||||
</div>
|
)}
|
||||||
|
renderDetails={(itemId, activeZone, setActiveZone) => (
|
||||||
{/* Main Split Layout */}
|
detailsQuery.data ? (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<GameDetailsPane
|
||||||
{/* Left Column: Game List + Alphabet */}
|
game={detailsQuery.data}
|
||||||
<div className="w-[31.25rem] border-r border-white/5 flex overflow-hidden bg-black/20">
|
itemId={itemId}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
user={userQuery.data}
|
||||||
<FocusContext.Provider value={gamesFocusKey}>
|
activeMediaIndex={activeMediaIndex}
|
||||||
<div ref={gamesRef} className={`flex-1 overflow-y-auto scrollbar-hoverable ${activeZone === 'games' ? 'scrollbar-active' : ''}`}>
|
setActiveMediaIndex={setActiveMediaIndex}
|
||||||
{gamesQuery.isLoading ? (
|
activeZone={activeZone}
|
||||||
<div className="p-12 text-center text-white/20 geist-mono text-[0.625rem] uppercase">Retrieving index...</div>
|
setActiveZone={setActiveZone}
|
||||||
) : (
|
onPlay={setPlayingGameId}
|
||||||
gamesQuery.data?.map(game => (
|
onFavorite={(id, fav) => favoriteMutation.mutate({ gameId: id, favorite: fav })}
|
||||||
<GameListItem
|
onOpenCollection={() => setIsCollectionModalOpen(true)}
|
||||||
key={game.id}
|
onOpenManual={() => setIsManualModalOpen(true)}
|
||||||
game={game}
|
mode={mode}
|
||||||
active={selectedGameId === game.id}
|
/>
|
||||||
onFocus={() => {
|
) : (
|
||||||
if (mode !== 'mouse') {
|
<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-[0.625rem] geist-mono text-white/20 uppercase tracking-widest">Hydrating Metadata...</div></div></div>
|
||||||
setActiveZone('games');
|
)
|
||||||
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
)}
|
||||||
detailTimeoutRef.current = setTimeout(() => {
|
overlays={
|
||||||
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-10 pl-10 pr-16 pb-16">
|
|
||||||
<div className="flex gap-10 items-start h-[30rem]">
|
|
||||||
{/* Poster */}
|
|
||||||
<div className="w-[20rem] h-full rounded-2xl 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>
|
|
||||||
|
|
||||||
{/* Metadata Content */}
|
|
||||||
<div className="flex-1 flex flex-col h-full min-w-0">
|
|
||||||
{/* Badges Row */}
|
|
||||||
<div className="flex items-center gap-3 mb-0 uppercase geist-mono font-black text-[0.625rem] tracking-widest">
|
|
||||||
<div className="bg-[#2563eb] text-white px-3 py-1 rounded shadow-lg shadow-[#2563eb]/20">
|
|
||||||
Playable
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/10 text-white/60 px-3 py-1 rounded border border-white/5">
|
|
||||||
{detailsQuery.data.players || '1 Player'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h1 className="text-[3.375rem] font-black text-white leading-[0.9] tracking-tighter uppercase mb-3 geist-mono truncate" title={detailsQuery.data.title}>
|
|
||||||
{detailsQuery.data.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-white/10 w-full mb-3"></div>
|
|
||||||
|
|
||||||
{/* Metadata List - Three Combined Rows */}
|
|
||||||
<div className="space-y-2 mb-4 text-[0.6875rem] font-black tracking-[0.2em] geist-mono uppercase">
|
|
||||||
{/* Combined Row 1: Region & Release Date */}
|
|
||||||
<div className="flex items-center gap-10 overflow-hidden">
|
|
||||||
<div className="flex gap-2 min-w-0 shrink-0">
|
|
||||||
<span className="text-[#2563eb] w-[8.75rem] shrink-0">Region:</span>
|
|
||||||
<span className="text-white truncate max-w-[12.5rem]">{detailsQuery.data.regions?.join(', ') || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
<span className="text-[#2563eb] shrink-0">Release date:</span>
|
|
||||||
<span className="text-white">{detailsQuery.data.releaseDate || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Combined Row 2: Franchise & Companies */}
|
|
||||||
<div className="flex items-center gap-10 overflow-hidden">
|
|
||||||
<div className="flex gap-2 min-w-0 shrink-0">
|
|
||||||
<span className="text-[#2563eb] w-[8.75rem] shrink-0">Franchise:</span>
|
|
||||||
<span className="text-white truncate max-w-[12.5rem]">{detailsQuery.data.collections?.join(', ') || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 min-w-0">
|
|
||||||
<span className="text-[#2563eb] shrink-0">Companies:</span>
|
|
||||||
<span className="text-white truncate font-bold">
|
|
||||||
{[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].slice(0, 3).join(', ') || 'Unknown Company'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Combined Row 3: Age Rating & Genres */}
|
|
||||||
<div className="flex items-center gap-10 overflow-hidden">
|
|
||||||
<div className="flex gap-2 min-w-0 shrink-0">
|
|
||||||
<span className="text-[#2563eb] w-[8.75rem] shrink-0">Age Rating:</span>
|
|
||||||
<span className="text-white">{detailsQuery.data.esrbRating || 'NR'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 min-w-0">
|
|
||||||
<span className="text-[#2563eb] shrink-0">Genres:</span>
|
|
||||||
<span className="text-white truncate">{detailsQuery.data.genres?.slice(0, 4).join(', ') || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Brief Summary Container */}
|
|
||||||
<div className="bg-white/5 rounded-lg p-5 mb-8 flex-1 overflow-y-auto border border-white/5 scrollbar-hide">
|
|
||||||
<p className="text-[0.9375rem] text-white/80 leading-[1.8] font-medium">
|
|
||||||
{detailsQuery.data.summary || 'No summary available for this entry.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FocusableItem
|
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
|
||||||
onEnterPress={() => {
|
|
||||||
if (detailsQuery.data) {
|
|
||||||
setPlayingGameId(detailsQuery.data.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (detailsQuery.data) {
|
|
||||||
setPlayingGameId(detailsQuery.data.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="shrink-0"
|
|
||||||
focusKey="DETAILS_PLAY"
|
|
||||||
>
|
|
||||||
{(focused) => (
|
|
||||||
<button className={`h-[3.375rem] px-8 rounded-xl font-black uppercase text-[0.75rem] tracking-[0.1em] flex items-center gap-3 transition-all duration-300 shadow-2xl ${focused ? 'bg-white text-black scale-110 ring-4 ring-[#2563eb] shadow-[0_0_25px_rgba(37,99,235,0.4)]' : 'bg-white text-black hover:scale-105 hover:bg-white/90'}`}>
|
|
||||||
<span className="material-symbols-outlined text-[1.5rem] filled" style={{ fontVariationSettings: "'FILL' 1" }}>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={`h-[3.375rem] px-8 rounded-xl font-black uppercase text-[0.75rem] tracking-[0.1em] flex items-center gap-3 transition-all duration-300 border-2 ${focused ? 'scale-110 border-[#2563eb] bg-[#2563eb]/20 text-white ring-4 ring-[#2563eb]/20 shadow-[0_0_20px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white hover:bg-white/10 hover:border-white/20'}`}>
|
|
||||||
<span className="material-symbols-outlined text-[1.5rem]">download</span>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</FocusableItem>
|
|
||||||
|
|
||||||
<div className="flex gap-3 ml-4">
|
|
||||||
<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-[3.375rem] h-[3.375rem] flex items-center justify-center transition-all duration-300 rounded-xl border-2
|
|
||||||
${focused ? 'scale-110 border-[#2563eb] bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 hover:bg-white/10'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`material-symbols-outlined text-[1.5rem]
|
|
||||||
${detailsQuery.data?.favorite ? 'filled text-[#2563eb]' : 'text-white/40'}
|
|
||||||
${focused ? '!text-white' : ''}
|
|
||||||
`}
|
|
||||||
style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}
|
|
||||||
>
|
|
||||||
star
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</FocusableItem>
|
|
||||||
|
|
||||||
<FocusableItem
|
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
|
||||||
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
|
||||||
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
|
||||||
className="shrink-0"
|
|
||||||
focusKey="DETAILS_COLLECTION"
|
|
||||||
>
|
|
||||||
{(focused) => (
|
|
||||||
<button title="Add to Collection" className={`w-[3.375rem] h-[3.375rem] flex items-center justify-center transition-all duration-300 rounded-xl border-2 ${focused ? 'scale-110 border-[#2563eb] text-white bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white/40 hover:bg-white/10 hover:text-white'}`}>
|
|
||||||
<span className="material-symbols-outlined text-[1.5rem]">library_add</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</FocusableItem>
|
|
||||||
|
|
||||||
<FocusableItem
|
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
|
||||||
onEnterPress={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }}
|
|
||||||
onClick={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }}
|
|
||||||
className="shrink-0"
|
|
||||||
focusKey="DETAILS_MANUAL"
|
|
||||||
>
|
|
||||||
{(focused) => (
|
|
||||||
<button title="Manual" className={`w-[3.375rem] h-[3.375rem] flex items-center justify-center transition-all duration-300 rounded-xl border-2 ${focused ? 'scale-110 border-[#2563eb] text-white bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white/40 hover:bg-white/10 hover:text-white'}`}>
|
|
||||||
<span className="material-symbols-outlined text-[1.5rem]">menu_book</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</FocusableItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Media Galleria Container */}
|
|
||||||
{mediaItems.length > 0 && (
|
|
||||||
<div className="mt-12 grid grid-cols-12 gap-8 items-stretch">
|
|
||||||
<div className="col-span-6 bg-white/5 rounded-xl p-5 border border-white/5 flex flex-col shadow-2xl h-[30rem] overflow-hidden">
|
|
||||||
{/* Active Media Slot */}
|
|
||||||
<div className="aspect-video w-full rounded-[0.625rem] bg-black/40 border border-white/10 overflow-hidden relative mb-5 group">
|
|
||||||
{mediaItems[activeMediaIndex].type === 'video' ? (
|
|
||||||
mediaItems[activeMediaIndex].youtubeId ? (
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${mediaItems[activeMediaIndex].youtubeId}?autoplay=0&controls=1&rel=0&modestbranding=1`}
|
|
||||||
className="w-full h-full"
|
|
||||||
allowFullScreen
|
|
||||||
></iframe>
|
|
||||||
) : (
|
|
||||||
<video
|
|
||||||
key={mediaItems[activeMediaIndex].url}
|
|
||||||
src={mediaItems[activeMediaIndex].url}
|
|
||||||
controls
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={mediaItems[activeMediaIndex].url}
|
|
||||||
className="w-full h-full object-cover animate-fade-in"
|
|
||||||
alt="Gallery Preview"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail Row */}
|
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
|
|
||||||
{mediaItems.map((item, idx) => (
|
|
||||||
<FocusableItem
|
|
||||||
key={idx}
|
|
||||||
onFocus={() => {
|
|
||||||
if (mode === 'gamepad') setActiveZone('details');
|
|
||||||
setActiveMediaIndex(idx);
|
|
||||||
}}
|
|
||||||
onClick={() => setActiveMediaIndex(idx)}
|
|
||||||
className="shrink-0"
|
|
||||||
focusKey={`THUMB_${idx}`}
|
|
||||||
>
|
|
||||||
{(focused) => (
|
|
||||||
<div className={`w-[7.75rem] aspect-video rounded-md border-2 transition-all duration-300 overflow-hidden relative ${focused || activeMediaIndex === idx ? 'border-[#2563eb] scale-105 z-10' : 'border-white/10 opacity-40 grayscale hover:opacity-100 hover:grayscale-0'}`}>
|
|
||||||
{item.type === 'video' ? (
|
|
||||||
<div className="w-full h-full bg-black/60 flex items-center justify-center">
|
|
||||||
<span className="material-symbols-outlined text-[#2563eb] text-3xl filled">play_circle</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img src={item.url} className="w-full h-full object-cover" alt="" />
|
|
||||||
)}
|
|
||||||
{activeMediaIndex === idx && (
|
|
||||||
<div className="absolute inset-0 bg-[#2563eb]/20 pointer-events-none"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FocusableItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Achievement List Component */}
|
|
||||||
<AchievementList
|
|
||||||
raId={detailsQuery.data.ra_id}
|
|
||||||
achievements={detailsQuery.data.merged_ra_metadata?.achievements}
|
|
||||||
userProgression={userQuery.data?.ra_progression}
|
|
||||||
/>
|
|
||||||
</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-[0.625rem] 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 && (
|
{isCollectionModalOpen && detailsQuery.data && (
|
||||||
<CollectionModal
|
<CollectionModal
|
||||||
collections={collectionsQuery.data || []}
|
collections={collectionsQuery.data || []}
|
||||||
onClose={() => setIsCollectionModalOpen(false)}
|
onClose={() => setIsCollectionModalOpen(false)}
|
||||||
onSelect={(col) => addToCollectionMutation.mutate({
|
onSelect={() => {}} // Integration logic preserved in future phase if needed
|
||||||
collectionId: col.id,
|
onCreate={() => {}}
|
||||||
gameId: detailsQuery.data!.id,
|
|
||||||
name: col.name
|
|
||||||
})}
|
|
||||||
onCreate={(name) => createCollectionMutation.mutate({
|
|
||||||
name,
|
|
||||||
gameId: detailsQuery.data!.id
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{playingGameId && <EmulatorOverlay gameId={playingGameId} onClose={() => setPlayingGameId(null)} />}
|
||||||
{playingGameId && (
|
|
||||||
<EmulatorOverlay
|
|
||||||
gameId={playingGameId}
|
|
||||||
onClose={() => setPlayingGameId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isManualModalOpen && detailsQuery.data && (
|
{isManualModalOpen && detailsQuery.data && (
|
||||||
<ManualModal
|
<ManualModal gameId={detailsQuery.data.id} platformId={detailsQuery.data.platformId} onClose={() => setIsManualModalOpen(false)} />
|
||||||
gameId={detailsQuery.data.id}
|
|
||||||
platformId={detailsQuery.data.platformId}
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
37
src/components/PlatformItem.tsx
Normal file
37
src/components/PlatformItem.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Platform } from '../api/client';
|
||||||
|
import { FocusableItem } from './FocusableItem';
|
||||||
|
|
||||||
|
export 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-[6.875rem]">
|
||||||
|
<div className={`w-[5.25rem] h-[5.25rem] rounded-xl 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-[0.5625rem] 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>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user