game page refinements
This commit is contained in:
@@ -16,11 +16,20 @@ export interface Platform {
|
|||||||
|
|
||||||
export interface DetailedGame extends Game {
|
export interface DetailedGame extends Game {
|
||||||
summary?: string;
|
summary?: string;
|
||||||
developer?: string;
|
developers?: string[];
|
||||||
publisher?: string;
|
publishers?: string[];
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
|
franchises?: string[]; // New field
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
screenshots?: string[];
|
screenshots?: string[];
|
||||||
|
fsName?: string;
|
||||||
|
regions?: string[];
|
||||||
|
players?: string;
|
||||||
|
rating?: number; // New field
|
||||||
|
esrbRating?: string;
|
||||||
|
ratingIconUrl?: string; // Icon for Age Rating
|
||||||
|
sha1?: string;
|
||||||
|
collections?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RommCollection {
|
export interface RommCollection {
|
||||||
@@ -79,6 +88,14 @@ const formatBytes = (bytes: number) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (unixSeconds: any) => {
|
||||||
|
if (!unixSeconds) return undefined;
|
||||||
|
const val = Number(unixSeconds);
|
||||||
|
if (isNaN(val) || val < 1000000) return String(unixSeconds);
|
||||||
|
const date = new Date(val * 1000);
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
export const rommApiClient = {
|
export const rommApiClient = {
|
||||||
get apiBase() {
|
get apiBase() {
|
||||||
const cleanUrl = getBaseUrl();
|
const cleanUrl = getBaseUrl();
|
||||||
@@ -173,14 +190,127 @@ export const rommApiClient = {
|
|||||||
if (!res.ok) throw new Error('Failed to fetch game details.');
|
if (!res.ok) throw new Error('Failed to fetch game details.');
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const game = mapRomToGame(json);
|
const game = mapRomToGame(json);
|
||||||
|
|
||||||
|
// Extract metadata with fallbacks across all providers
|
||||||
|
const providers = [
|
||||||
|
json.latest_igdb_metadata, json.igdb_metadata,
|
||||||
|
json.latest_moby_metadata, json.moby_metadata,
|
||||||
|
json.latest_screenscraper_metadata, json.screenscraper_metadata,
|
||||||
|
json.latest_launchbox_metadata, json.launchbox_metadata,
|
||||||
|
json.latest_steamgrid_metadata, json.steamgrid_metadata
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const getFirst = (key: string) => {
|
||||||
|
for (const p of providers) {
|
||||||
|
if (p[key] !== undefined && p[key] !== null) return p[key];
|
||||||
|
}
|
||||||
|
return json[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAll = (key: string) => {
|
||||||
|
const all: any[] = [];
|
||||||
|
if (Array.isArray(json[key])) all.push(...json[key]);
|
||||||
|
for (const p of providers) {
|
||||||
|
if (Array.isArray(p[key])) all.push(...p[key]);
|
||||||
|
}
|
||||||
|
return Array.from(new Set(all)).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract Age Ratings from all providers (array format for IGDB/ScreenScraper)
|
||||||
|
const ageRatings = new Set<string>();
|
||||||
|
for (const p of providers) {
|
||||||
|
if (Array.isArray(p.age_ratings)) {
|
||||||
|
p.age_ratings.forEach((ar: any) => {
|
||||||
|
if (ar.rating) ageRatings.add(String(ar.rating));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pRating = p.esrb_rating || p.esrb || p.pegi_rating || p.pegi;
|
||||||
|
if (pRating && typeof pRating === 'string') ageRatings.add(pRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out placeholders like "Not Rated" or "Unknown" if we have valid ratings
|
||||||
|
let finalRatings = Array.from(ageRatings).filter(Boolean);
|
||||||
|
const placeholders = ['not rated', 'unknown', 'pending', 'nr', 'rp', 'rating pending'];
|
||||||
|
if (finalRatings.some(r => !placeholders.includes(r.toLowerCase()))) {
|
||||||
|
finalRatings = finalRatings.filter(r => !placeholders.includes(r.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageRatingStr = finalRatings.join(' / ') || (json.esrb_rating || json.esrb);
|
||||||
|
|
||||||
|
// Defensive mapping for regions
|
||||||
|
const regionsRaw = [
|
||||||
|
...(json.rom_regions || []),
|
||||||
|
...(json.region ? [json.region] : []),
|
||||||
|
...(getFirst('regions') || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const regions = Array.from(new Set(regionsRaw.map((r: any) =>
|
||||||
|
typeof r === 'string' ? r : (r.display_name || r.name || String(r))
|
||||||
|
))).filter(Boolean);
|
||||||
|
|
||||||
|
const extractCompanies = (type: 'developer' | 'publisher') => {
|
||||||
|
const companies = new Set<string>();
|
||||||
|
|
||||||
|
// 1. Root lists
|
||||||
|
const rootList = type === 'developer' ? (json.developers || json.companies) : (json.publishers || json.companies);
|
||||||
|
if (Array.isArray(rootList)) {
|
||||||
|
rootList.forEach(c => companies.add(typeof c === 'string' ? c : (c.name || c.display_name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Provider metadata
|
||||||
|
for (const p of providers) {
|
||||||
|
// IGDB involved_companies
|
||||||
|
if (Array.isArray(p.involved_companies)) {
|
||||||
|
p.involved_companies.forEach((ic: any) => {
|
||||||
|
if (ic[type] && ic.company?.name) companies.add(ic.company.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// General companies/developers/publishers lists
|
||||||
|
const pList = p.companies || (type === 'developer' ? (p.developers || p.developer_companies) : (p.publishers || p.publisher_companies));
|
||||||
|
if (Array.isArray(pList)) {
|
||||||
|
pList.forEach((c: any) => companies.add(typeof c === 'string' ? c : (c.name || c.display_name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Root singulars
|
||||||
|
const rootSingular = type === 'developer' ? (json.developer || json.developer_name) : (json.publisher || json.publisher_name);
|
||||||
|
if (rootSingular && typeof rootSingular === 'string') companies.add(rootSingular);
|
||||||
|
|
||||||
|
return Array.from(companies).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const devList = extractCompanies('developer');
|
||||||
|
const pubList = extractCompanies('publisher');
|
||||||
|
|
||||||
|
// Rating extraction with ScreenScraper/Launchbox fallbacks
|
||||||
|
let ratingVal = getFirst('rating') || getFirst('total_rating') || getFirst('aggregated_rating') || getFirst('ss_score') || getFirst('community_rating');
|
||||||
|
if (ratingVal && String(ratingVal).includes('.')) {
|
||||||
|
const parsed = parseFloat(String(ratingVal));
|
||||||
|
// Normalize 0-10 or 0-5 scales to 100%
|
||||||
|
if (parsed <= 10) ratingVal = parsed * 10;
|
||||||
|
else if (parsed <= 5) ratingVal = parsed * 20;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...game,
|
...game,
|
||||||
summary: json.summary,
|
summary: json.summary || getFirst('summary') || getFirst('description'),
|
||||||
developer: json.developer,
|
developers: devList,
|
||||||
publisher: json.publisher,
|
publishers: pubList,
|
||||||
genres: json.genres,
|
genres: getAll('genres').map(g => typeof g === 'string' ? g : (g.name || g.display_name)),
|
||||||
releaseDate: json.release_date,
|
franchises: getAll('franchises').map(f => typeof f === 'string' ? f : (f.name || f.display_name)),
|
||||||
screenshots: (json.screenshots || []).map((s: any) => getFullImageUrl(s.url) || '')
|
releaseDate: formatDate(json.release_date || getFirst('first_release_date') || getFirst('release_date')),
|
||||||
|
screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''),
|
||||||
|
fsName: json.fs_name,
|
||||||
|
regions: regions.length > 0 ? regions : ['Global'],
|
||||||
|
players: json.players || getFirst('total_players') || getFirst('players'),
|
||||||
|
rating: ratingVal ? Math.round(Number(ratingVal)) : undefined,
|
||||||
|
esrbRating: ageRatingStr,
|
||||||
|
sha1: json.hashes?.sha1,
|
||||||
|
collections: Array.from(new Set([
|
||||||
|
...(json.rom_collections || []).map((c: any) => c.name),
|
||||||
|
...(getFirst('collections') || [])
|
||||||
|
])).filter(Boolean),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface FocusableItemProps {
|
|||||||
focusKey?: string;
|
focusKey?: string;
|
||||||
scrollOptions?: ScrollIntoViewOptions;
|
scrollOptions?: ScrollIntoViewOptions;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => {
|
const FocusableItem = ({ onFocus, onEnterPress, onClick, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => {
|
||||||
const { mode } = useInputMode();
|
const { mode } = useInputMode();
|
||||||
const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({
|
const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({
|
||||||
onFocus,
|
onFocus,
|
||||||
@@ -22,10 +23,10 @@ const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, s
|
|||||||
focusKey
|
focusKey
|
||||||
});
|
});
|
||||||
|
|
||||||
const focused = mode === 'gamepad' ? rawFocused : false;
|
const focused = mode !== 'mouse' ? rawFocused : false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'gamepad' && (focused || active) && ref.current) {
|
if (mode !== 'mouse' && (focused || active) && ref.current) {
|
||||||
if (scrollOptions) {
|
if (scrollOptions) {
|
||||||
ref.current.scrollIntoView(scrollOptions);
|
ref.current.scrollIntoView(scrollOptions);
|
||||||
} else {
|
} else {
|
||||||
@@ -35,7 +36,14 @@ const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, s
|
|||||||
}, [focused, active, mode, scrollOptions]);
|
}, [focused, active, mode, scrollOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} data-focusable-key={internalFocusKey} className={`${className} ${focused ? 'focused' : ''}`}>
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-focusable-key={internalFocusKey}
|
||||||
|
className={`${className} ${focused ? 'focused' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (onClick) onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children(focused)}
|
{children(focused)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -44,6 +52,7 @@ const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, s
|
|||||||
const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => (
|
const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => (
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onFocus={onSelect}
|
onFocus={onSelect}
|
||||||
|
onClick={onSelect}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
focusKey={`PLATFORM_${platform.id}`}
|
focusKey={`PLATFORM_${platform.id}`}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -77,6 +86,7 @@ const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, acti
|
|||||||
const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, onFocus: () => void }) => (
|
const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, onFocus: () => void }) => (
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
|
onClick={onFocus}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
focusKey={`GAME_${game.id}`}
|
focusKey={`GAME_${game.id}`}
|
||||||
scrollOptions={{ behavior: 'smooth', block: 'nearest' }}
|
scrollOptions={{ behavior: 'smooth', block: 'nearest' }}
|
||||||
@@ -116,6 +126,7 @@ const AlphabetScroller = ({
|
|||||||
key={letter}
|
key={letter}
|
||||||
onFocus={() => {}}
|
onFocus={() => {}}
|
||||||
onEnterPress={() => onLetterPress(letter)}
|
onEnterPress={() => onLetterPress(letter)}
|
||||||
|
onClick={() => onLetterPress(letter)}
|
||||||
className="w-full flex justify-center py-1"
|
className="w-full flex justify-center py-1"
|
||||||
focusKey={`LETTER_${letter}`}
|
focusKey={`LETTER_${letter}`}
|
||||||
scrollOptions={{ behavior: 'smooth', block: 'center' }}
|
scrollOptions={{ behavior: 'smooth', block: 'center' }}
|
||||||
@@ -195,13 +206,13 @@ export const GamesPage = () => {
|
|||||||
}, [filteredPlatforms, selectedPlatformId]);
|
}, [filteredPlatforms, selectedPlatformId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gamesQuery.data && gamesQuery.data.length > 0 && !selectedGameId) {
|
if (mode !== 'mouse' && (gamesQuery.data?.length ?? 0) > 0 && !selectedGameId) {
|
||||||
setSelectedGameId(gamesQuery.data[0].id);
|
setSelectedGameId(gamesQuery.data?.[0]?.id ?? null);
|
||||||
}
|
}
|
||||||
}, [gamesQuery.data, selectedGameId]);
|
}, [gamesQuery.data, selectedGameId, mode]);
|
||||||
|
|
||||||
const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => {
|
const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => {
|
||||||
if (mode === 'gamepad') {
|
if (mode !== 'mouse') {
|
||||||
if (!options.skipZoneChange) {
|
if (!options.skipZoneChange) {
|
||||||
setActiveZone('platforms');
|
setActiveZone('platforms');
|
||||||
}
|
}
|
||||||
@@ -266,7 +277,7 @@ export const GamesPage = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (closestKey && mode === 'gamepad') {
|
if (closestKey && mode !== 'mouse') {
|
||||||
console.log("[MagneticFocus] Setting focus to:", closestKey);
|
console.log("[MagneticFocus] Setting focus to:", closestKey);
|
||||||
setFocus(closestKey);
|
setFocus(closestKey);
|
||||||
}
|
}
|
||||||
@@ -370,7 +381,7 @@ export const GamesPage = () => {
|
|||||||
game={game}
|
game={game}
|
||||||
active={selectedGameId === game.id}
|
active={selectedGameId === game.id}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (mode === 'gamepad') {
|
if (mode !== 'mouse') {
|
||||||
setActiveZone('games');
|
setActiveZone('games');
|
||||||
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
||||||
detailTimeoutRef.current = setTimeout(() => {
|
detailTimeoutRef.current = setTimeout(() => {
|
||||||
@@ -403,8 +414,8 @@ export const GamesPage = () => {
|
|||||||
<FocusContext.Provider value={detailsFocusKey}>
|
<FocusContext.Provider value={detailsFocusKey}>
|
||||||
<div ref={detailsRef} className={`flex-1 overflow-y-auto relative scrollbar-hoverable ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
<div ref={detailsRef} className={`flex-1 overflow-y-auto relative scrollbar-hoverable ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
||||||
{detailsQuery.data ? (
|
{detailsQuery.data ? (
|
||||||
<div className="p-16">
|
<div className="pt-[20px] pl-[20px] pr-16 pb-16">
|
||||||
<div className="flex gap-12 items-start">
|
<div className="flex gap-[20px] items-start">
|
||||||
{/* Poster */}
|
{/* Poster */}
|
||||||
<div className="w-[320px] rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 sticky top-0 group">
|
<div className="w-[320px] rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 sticky top-0 group">
|
||||||
<img
|
<img
|
||||||
@@ -422,23 +433,68 @@ export const GamesPage = () => {
|
|||||||
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
|
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl font-black text-white mb-4 tracking-tighter leading-none">{detailsQuery.data.title}</h1>
|
<h1 className="text-5xl font-black text-white tracking-tighter leading-none mb-6">{detailsQuery.data.title}</h1>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-2 mb-10 items-center">
|
{/* Row 1: Regions, Release Date & Rating */}
|
||||||
|
<div className="flex items-center gap-6 mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-[16px] text-[#2563eb]">developer_board</span>
|
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Region:</span>
|
||||||
<span className="text-[11px] font-bold text-white/80 uppercase geist-mono">{detailsQuery.data.developer || 'Unknown Dev'}</span>
|
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
||||||
|
{detailsQuery.data.regions?.join(', ') || 'Global'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-[16px] text-[#2563eb]">calendar_today</span>
|
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Release Date:</span>
|
||||||
<span className="text-[11px] font-bold text-white/80 uppercase geist-mono">{detailsQuery.data.releaseDate || 'N/A'}</span>
|
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
||||||
|
{detailsQuery.data.releaseDate || 'Unknown'}
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{/* Row 2: Companies & Franchises */}
|
||||||
|
<div className="flex items-center gap-6 mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-[16px] text-[#2563eb]">sports_esports</span>
|
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Companies:</span>
|
||||||
<span className="text-[11px] font-bold text-white/80 uppercase geist-mono">{detailsQuery.data.system}</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>
|
||||||
|
|
||||||
|
{/* Row 3: Genres */}
|
||||||
|
<div className="flex items-center gap-6 mb-4">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Age Rating Text */}
|
||||||
|
{detailsQuery.data.esrbRating && (
|
||||||
|
<div className="flex items-center gap-2 mb-10">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4 mb-12">
|
<div className="flex gap-4 mb-12">
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||||
@@ -476,17 +532,6 @@ export const GamesPage = () => {
|
|||||||
{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."}
|
{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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{detailsQuery.data.genres && detailsQuery.data.genres.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h3 className="text-[10px] font-black text-[#2563eb] uppercase tracking-[0.4em] mb-4">Classifications</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{detailsQuery.data.genres.map(g => (
|
|
||||||
<span key={g} className="px-3 py-1 bg-white/5 border border-white/5 rounded-full text-[9px] font-bold text-white/60 uppercase tracking-widest">{g}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ export const TopNav = () => {
|
|||||||
<div className="w-px h-4 bg-white/10 mx-2"></div>
|
<div className="w-px h-4 bg-white/10 mx-2"></div>
|
||||||
|
|
||||||
{/* Active Hardware Indicator */}
|
{/* Active Hardware Indicator */}
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-white/5 border border-white/5 rounded-full ring-1 ring-white/10 shadow-inner text-[#2563eb]" title={mode === 'gamepad' ? 'Gamepad Active' : 'Mouse/Keyboard Active'}>
|
<div className="flex items-center justify-center w-8 h-8 bg-white/5 border border-white/5 rounded-full ring-1 ring-white/10 shadow-inner text-[#2563eb]" title={mode === 'gamepad' ? 'Gamepad Active' : mode === 'keyboard' ? 'Keyboard Active' : 'Mouse Active'}>
|
||||||
<span className="material-symbols-outlined text-[16px]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
<span className="material-symbols-outlined text-[16px]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||||
{mode === 'gamepad' ? 'sports_esports' : 'mouse'}
|
{mode === 'gamepad' ? 'sports_esports' : mode === 'keyboard' ? 'keyboard_alt' : 'mouse'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
type InputMode = 'mouse' | 'gamepad';
|
type InputMode = 'mouse' | 'gamepad' | 'keyboard';
|
||||||
|
|
||||||
interface InputModeContextType {
|
interface InputModeContextType {
|
||||||
mode: InputMode;
|
mode: InputMode;
|
||||||
@@ -14,27 +14,34 @@ export const InputModeProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Spatial Engine Override
|
// Spatial Engine Override
|
||||||
if (mode === 'mouse') {
|
document.body.classList.remove('gamepad-active', 'keyboard-active');
|
||||||
document.body.classList.remove('gamepad-active');
|
if (mode === 'gamepad') {
|
||||||
} else {
|
|
||||||
document.body.classList.add('gamepad-active');
|
document.body.classList.add('gamepad-active');
|
||||||
|
} else if (mode === 'keyboard') {
|
||||||
|
document.body.classList.add('keyboard-active');
|
||||||
}
|
}
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMouseMove = () => {
|
const onMouseMove = () => setMode('mouse');
|
||||||
setMode('mouse');
|
const onMouseDown = () => setMode('mouse');
|
||||||
};
|
|
||||||
const onMouseDown = () => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
setMode('mouse');
|
// Common navigation keys
|
||||||
|
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab', ' ', 'Backspace'];
|
||||||
|
if (navKeys.includes(e.key)) {
|
||||||
|
setMode('keyboard');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||||
window.addEventListener('mousedown', onMouseDown, { passive: true });
|
window.addEventListener('mousedown', onMouseDown, { passive: true });
|
||||||
|
window.addEventListener('keydown', onKeyDown, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
window.removeEventListener('mousedown', onMouseDown);
|
window.removeEventListener('mousedown', onMouseDown);
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user