more game page development
This commit is contained in:
@@ -30,11 +30,14 @@ export interface DetailedGame extends Game {
|
||||
ratingIconUrl?: string; // Icon for Age Rating
|
||||
sha1?: string;
|
||||
collections?: string[];
|
||||
favorite?: boolean;
|
||||
manualUrl?: string;
|
||||
}
|
||||
|
||||
export interface RommCollection {
|
||||
id: string;
|
||||
name: string;
|
||||
is_favorite?: boolean;
|
||||
ownerRole: 'admin' | 'user';
|
||||
games: Game[];
|
||||
coverUrl?: string; // RomM collections can have intrinsic covers
|
||||
@@ -119,7 +122,7 @@ export const rommApiClient = {
|
||||
data.append('password', password);
|
||||
data.append('grant_type', 'password');
|
||||
// Ensure we request the explicit permissions RomM FastAPI needs
|
||||
data.append('scope', 'me.read roms.read collections.read assets.read platforms.read');
|
||||
data.append('scope', 'me.read roms.read roms.user.write collections.read collections.write assets.read platforms.read');
|
||||
|
||||
const res = await fetch(`${this.apiBase}/token`, {
|
||||
method: 'POST',
|
||||
@@ -216,6 +219,11 @@ export const rommApiClient = {
|
||||
return Array.from(new Set(all)).filter(Boolean);
|
||||
};
|
||||
|
||||
const sanitize = (val: any): string => {
|
||||
if (typeof val !== 'string') return String(val?.name || val?.display_name || val || '');
|
||||
return val.trim().replace(/;+$/, '');
|
||||
};
|
||||
|
||||
// Extract Age Ratings from all providers (array format for IGDB/ScreenScraper)
|
||||
const ageRatings = new Set<string>();
|
||||
for (const p of providers) {
|
||||
@@ -254,7 +262,7 @@ export const rommApiClient = {
|
||||
// 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)));
|
||||
rootList.forEach(c => companies.add(sanitize(c)));
|
||||
}
|
||||
|
||||
// 2. Provider metadata
|
||||
@@ -262,20 +270,20 @@ export const rommApiClient = {
|
||||
// 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);
|
||||
if (ic[type] && ic.company?.name) companies.add(sanitize(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)));
|
||||
pList.forEach((c: any) => companies.add(sanitize(c)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (rootSingular && typeof rootSingular === 'string') companies.add(sanitize(rootSingular));
|
||||
|
||||
return Array.from(companies).filter(Boolean);
|
||||
};
|
||||
@@ -297,8 +305,8 @@ export const rommApiClient = {
|
||||
summary: json.summary || getFirst('summary') || getFirst('description'),
|
||||
developers: devList,
|
||||
publishers: pubList,
|
||||
genres: getAll('genres').map(g => typeof g === 'string' ? g : (g.name || g.display_name)),
|
||||
franchises: getAll('franchises').map(f => typeof f === 'string' ? f : (f.name || f.display_name)),
|
||||
genres: getAll('genres').map(sanitize).filter(Boolean),
|
||||
franchises: getAll('franchises').map(sanitize).filter(Boolean),
|
||||
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,
|
||||
@@ -311,6 +319,8 @@ export const rommApiClient = {
|
||||
...(json.rom_collections || []).map((c: any) => c.name),
|
||||
...(getFirst('collections') || [])
|
||||
])).filter(Boolean),
|
||||
favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'),
|
||||
manualUrl: getFullImageUrl(json.url_manual),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -341,6 +351,7 @@ export const rommApiClient = {
|
||||
return {
|
||||
id: String(c.id),
|
||||
name: c.name,
|
||||
is_favorite: c.is_favorite || false,
|
||||
ownerRole: role,
|
||||
games: gamesItems,
|
||||
coverUrl
|
||||
@@ -350,6 +361,67 @@ export const rommApiClient = {
|
||||
return mapped;
|
||||
},
|
||||
|
||||
async fetchCollectionDetails(collectionId: string): Promise<any> {
|
||||
const res = await fetch(`${this.apiBase}/collections/${collectionId}`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch collection details.');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async updateCollection(collectionId: string, data: { name: string; description?: string; rom_ids: number[] }): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('description', data.description || '');
|
||||
formData.append('rom_ids', JSON.stringify(data.rom_ids));
|
||||
|
||||
const res = await fetch(`${this.apiBase}/collections/${collectionId}?is_public=false&remove_cover=false`, {
|
||||
method: 'PUT',
|
||||
headers: this.headers,
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update collection.');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async toggleFavorite(gameId: string, favorite: boolean): Promise<any> {
|
||||
// 1. Find the Favorites collection
|
||||
const collections = await this.fetchCollections();
|
||||
const favCol = collections.find(c => c.is_favorite);
|
||||
if (!favCol) throw new Error("Could not find Favorites collection");
|
||||
|
||||
// 2. Fetch current member IDs (full list for collection-tier update)
|
||||
const details = await this.fetchCollectionDetails(favCol.id);
|
||||
let ids: number[] = details.rom_ids || [];
|
||||
|
||||
// 3. Toggle membership
|
||||
const numId = parseInt(gameId);
|
||||
if (favorite) {
|
||||
if (!ids.includes(numId)) ids.push(numId);
|
||||
} else {
|
||||
ids = ids.filter(id => id !== numId);
|
||||
}
|
||||
|
||||
// 4. Persistence via Multipart-Encoded PUT
|
||||
return this.updateCollection(favCol.id, {
|
||||
name: favCol.name || 'Favorites',
|
||||
rom_ids: ids
|
||||
});
|
||||
},
|
||||
|
||||
async createCollection(name: string, description: string = ''): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('description', description);
|
||||
formData.append('rom_ids', '[]'); // Start empty
|
||||
|
||||
const res = await fetch(`${this.apiBase}/collections?is_public=false`, {
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create collection.');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async fetchCurrentUser(): Promise<UserProfile> {
|
||||
const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch user profile.');
|
||||
|
||||
216
src/components/CollectionModal.tsx
Normal file
216
src/components/CollectionModal.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useFocusable, FocusContext, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { RommCollection } from '../api/client';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
import { VirtualKeyboard } from './VirtualKeyboard';
|
||||
|
||||
interface CollectionModalProps {
|
||||
onClose: () => void;
|
||||
onSelect: (collection: RommCollection) => void;
|
||||
onCreate: (name: string) => void;
|
||||
collections: RommCollection[];
|
||||
}
|
||||
|
||||
const CollectionItem = ({ collection, onSelect }: { collection: RommCollection, onSelect: () => void }) => {
|
||||
const { ref, focused } = useFocusable({
|
||||
onEnterPress: onSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onSelect}
|
||||
className={`p-2.5 px-4 mb-1.5 rounded-[12px] border transition-all duration-300 cursor-pointer flex items-center gap-3 ${
|
||||
focused
|
||||
? 'border-[#2563eb] bg-[#2563eb]/20 scale-[1.01] text-white shadow-[0_0_15px_rgba(37,99,235,0.15)] font-bold'
|
||||
: 'border-white/5 bg-white/5 text-white/50 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-[8px] flex items-center justify-center transition-colors ${focused ? 'bg-[#2563eb] text-white' : 'bg-white/5 text-white/30'}`}>
|
||||
<span className="material-symbols-outlined text-[16px]">folder</span>
|
||||
</div>
|
||||
<span className="text-[14px] tracking-tight">{collection.name}</span>
|
||||
{focused && (
|
||||
<span className="ml-auto material-symbols-outlined text-[16px] animate-pulse opacity-50">
|
||||
chevron_right
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderButton = ({ onClick, children, className, title }: { onClick: () => void, children: React.ReactNode, className: string, title?: string }) => {
|
||||
const { ref, focused } = useFocusable({
|
||||
onEnterPress: onClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={`${className} ${focused ? 'ring-2 ring-white/50 bg-[#2563eb]/80' : 'hover:bg-white/10'}`}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionModal = ({ onClose, onSelect, onCreate, collections }: CollectionModalProps) => {
|
||||
const { ref, focusKey } = useFocusable({
|
||||
isFocusBoundary: true,
|
||||
focusKey: 'COLLECTION_MODAL'
|
||||
});
|
||||
|
||||
const { mode } = useInputMode();
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Force focus into modal after a short delay for registration
|
||||
const timer = setTimeout(() => {
|
||||
setFocus('COLLECTION_MODAL');
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating && mode === 'gamepad') {
|
||||
// Small delay to ensure VirtualKeyboard is mounted and registered its focus key
|
||||
const timer = setTimeout(() => {
|
||||
setFocus('VIRTUAL_KEYBOARD_ZONE');
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isCreating, mode]);
|
||||
|
||||
const handleKeyPress = (key: string) => {
|
||||
if (key === 'BACKSPACE') {
|
||||
setNewCollectionName((prev: string) => prev.slice(0, -1));
|
||||
} else if (key === 'SPACE') {
|
||||
setNewCollectionName((prev: string) => prev + ' ');
|
||||
} else if (key === 'ENTER') {
|
||||
if (newCollectionName.trim()) {
|
||||
onCreate(newCollectionName.trim());
|
||||
setNewCollectionName('');
|
||||
setIsCreating(false);
|
||||
}
|
||||
} else {
|
||||
setNewCollectionName((prev: string) => prev + key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-black/60 backdrop-blur-xl animate-in fade-in duration-500"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full max-w-[660px] bg-[#121416]/95 border border-white/10 rounded-[32px] overflow-hidden shadow-[0_32px_128px_-16px_rgba(0,0,0,0.8)] flex flex-col max-h-[70vh] animate-in zoom-in-95 slide-in-from-bottom-10 duration-500 ring-1 ring-white/5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header - Minimalist Atelier */}
|
||||
<div className="p-6 pb-4 border-b border-white/5 bg-gradient-to-r from-[#2563eb]/5 to-transparent flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-4 bg-[#2563eb] rounded-full" />
|
||||
<h2 className="text-[18px] font-black text-white/90 tracking-tighter uppercase geist-mono">Add to Collection</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderButton
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="flex items-center gap-2 px-4 h-9 rounded-[10px] bg-[#2563eb] text-white text-[11px] font-black uppercase tracking-widest transition-all shadow-lg shadow-[#2563eb]/20"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
New
|
||||
</HeaderButton>
|
||||
|
||||
<HeaderButton
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-[10px] bg-white/5 text-white/40 hover:text-white transition-all"
|
||||
title="Close"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</HeaderButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
|
||||
{/* Create New Flow (Inline at top) */}
|
||||
{isCreating && (
|
||||
<div className="p-1 mb-4 rounded-[18px] bg-gradient-to-br from-[#2563eb] to-[#1d4ed8] shadow-[0_0_40px_rgba(37,99,235,0.2)] animate-in slide-in-from-top-4 duration-300">
|
||||
<div className="bg-[#0c0d0e] rounded-[17px] p-4 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-[20px]">edit_note</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="New collection name..."
|
||||
className="flex-1 bg-transparent border-none outline-none text-white placeholder-white/10 text-[15px] font-medium"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newCollectionName.trim()) {
|
||||
onCreate(newCollectionName.trim());
|
||||
setNewCollectionName('');
|
||||
setIsCreating(false);
|
||||
}
|
||||
if (e.key === 'Escape') setIsCreating(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsCreating(false)}
|
||||
className="p-1.5 text-white/20 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
<button
|
||||
disabled={!newCollectionName.trim()}
|
||||
onClick={() => {
|
||||
onCreate(newCollectionName.trim());
|
||||
setNewCollectionName('');
|
||||
setIsCreating(false);
|
||||
}}
|
||||
className="p-1.5 bg-[#2563eb] text-white rounded-[6px] hover:bg-[#1d4ed8] disabled:opacity-50 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">check</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreating && mode === 'gamepad' && (
|
||||
<VirtualKeyboard
|
||||
onKeyPress={handleKeyPress}
|
||||
onClose={() => setIsCreating(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{collections.map((col) => (
|
||||
<CollectionItem
|
||||
key={col.id}
|
||||
collection={col}
|
||||
onSelect={() => onSelect(col)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{collections.length === 0 && !isCreating && (
|
||||
<div className="py-12 text-center rounded-[24px] border border-dashed border-white/5 bg-white/[0.01]">
|
||||
<span className="material-symbols-outlined text-white/5 mb-2 block text-[32px]">inventory_2</span>
|
||||
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">Archive Empty</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Atelier Trim - Removed as requested */}
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { CollectionModal } from './CollectionModal';
|
||||
import { rommApiClient, Platform, Game } from '../api/client';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
@@ -148,6 +149,7 @@ export const GamesPage = () => {
|
||||
const { mode } = useInputMode();
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
||||
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
||||
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
||||
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
|
||||
const detailTimeoutRef = React.useRef<any>(null);
|
||||
|
||||
@@ -193,6 +195,54 @@ export const GamesPage = () => {
|
||||
enabled: !!selectedGameId
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: ({ gameId, favorite }: { gameId: string, favorite: boolean }) =>
|
||||
rommApiClient.toggleFavorite(gameId, favorite),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
||||
}
|
||||
});
|
||||
|
||||
const collectionsQuery = useQuery({
|
||||
queryKey: ['collections'],
|
||||
queryFn: () => rommApiClient.fetchCollections()
|
||||
});
|
||||
|
||||
const addToCollectionMutation = useMutation({
|
||||
mutationFn: async ({ collectionId, gameId, name }: { collectionId: string, gameId: string, name: string }) => {
|
||||
const details = await rommApiClient.fetchCollectionDetails(collectionId);
|
||||
const ids: number[] = details.rom_ids || [];
|
||||
const numId = parseInt(gameId);
|
||||
if (!ids.includes(numId)) {
|
||||
ids.push(numId);
|
||||
return rommApiClient.updateCollection(collectionId, {
|
||||
name: name,
|
||||
rom_ids: ids
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
||||
setIsCollectionModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const createCollectionMutation = useMutation({
|
||||
mutationFn: async ({ name, gameId }: { name: string, gameId: string }) => {
|
||||
const newCol = await rommApiClient.createCollection(name);
|
||||
return rommApiClient.updateCollection(newCol.id, {
|
||||
name: newCol.name,
|
||||
rom_ids: [parseInt(gameId)]
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gameDetails'] });
|
||||
setIsCollectionModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlatforms = React.useMemo(() =>
|
||||
platformsQuery.data?.filter(p => p.romCount > 0) || [],
|
||||
[platformsQuery.data]
|
||||
@@ -232,7 +282,7 @@ export const GamesPage = () => {
|
||||
if (!gamesQuery.data) return [];
|
||||
const letters = new Set<string>();
|
||||
gamesQuery.data.forEach(game => {
|
||||
const firstChar = game.title.charAt(0).toUpperCase();
|
||||
const firstChar = (game.title || '').charAt(0).toUpperCase();
|
||||
if (/[A-Z]/.test(firstChar)) {
|
||||
letters.add(firstChar);
|
||||
} else {
|
||||
@@ -245,7 +295,7 @@ export const GamesPage = () => {
|
||||
const handleLetterPress = (letter: string) => {
|
||||
if (!gamesQuery.data) return;
|
||||
const game = gamesQuery.data.find(g => {
|
||||
const firstChar = g.title.charAt(0).toUpperCase();
|
||||
const firstChar = (g.title || '').charAt(0).toUpperCase();
|
||||
return letter === '#' ? !/[A-Z]/.test(firstChar) : firstChar === letter;
|
||||
});
|
||||
if (game) {
|
||||
@@ -277,7 +327,7 @@ export const GamesPage = () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (closestKey && mode !== 'mouse') {
|
||||
if (closestKey && mode !== 'mouse' && !isCollectionModalOpen) {
|
||||
console.log("[MagneticFocus] Setting focus to:", closestKey);
|
||||
setFocus(closestKey);
|
||||
}
|
||||
@@ -414,28 +464,30 @@ export const GamesPage = () => {
|
||||
<FocusContext.Provider value={detailsFocusKey}>
|
||||
<div ref={detailsRef} className={`flex-1 overflow-y-auto relative scrollbar-hoverable ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
||||
{detailsQuery.data ? (
|
||||
<div className="pt-[20px] pl-[20px] pr-16 pb-16">
|
||||
<div className="flex gap-[20px] items-start">
|
||||
<div className="pt-[40px] pl-[40px] pr-16 pb-16">
|
||||
<div className="flex gap-[40px] items-start h-[480px]">
|
||||
{/* Poster */}
|
||||
<div className="w-[320px] rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 sticky top-0 group">
|
||||
<div className="w-[320px] h-full rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 relative group">
|
||||
<img
|
||||
src={detailsQuery.data.coverUrl}
|
||||
className="w-full aspect-[2/3] object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
alt=""
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-60"></div>
|
||||
</div>
|
||||
|
||||
{/* Info Pane */}
|
||||
<div className="flex-1 pt-4">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-3 py-1 bg-[#2563eb] text-white text-[9px] font-black uppercase tracking-widest rounded-sm">PLAYABLE</span>
|
||||
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-black text-white tracking-tighter leading-none mb-6">{detailsQuery.data.title}</h1>
|
||||
<h1 className="text-5xl font-black text-white tracking-tighter leading-none mb-6 truncate whitespace-nowrap overflow-hidden">
|
||||
{detailsQuery.data.title}
|
||||
</h1>
|
||||
|
||||
{/* Row 1: Regions, Release Date & Rating */}
|
||||
{/* Metadata rows with consistent spacing (mb-2) */}
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Region:</span>
|
||||
@@ -457,7 +509,6 @@ export const GamesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Companies & Franchises */}
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Companies:</span>
|
||||
@@ -475,8 +526,7 @@ export const GamesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Genres */}
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Genres:</span>
|
||||
<span className="text-[11px] font-bold text-white/40 uppercase geist-mono tracking-wider italic">
|
||||
@@ -485,9 +535,8 @@ export const GamesPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Age Rating Text */}
|
||||
{detailsQuery.data.esrbRating && (
|
||||
<div className="flex items-center gap-2 mb-10">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Age Rating:</span>
|
||||
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
||||
{detailsQuery.data.esrbRating}
|
||||
@@ -495,15 +544,26 @@ export const GamesPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 mb-12">
|
||||
{/* Summary Section - Fixed Height & Scrollable */}
|
||||
<div className="bg-white/5 rounded-[8px] border border-white/5 p-4 flex-1 mb-6 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin pr-2">
|
||||
<p className="text-white/60 text-sm leading-relaxed font-medium">
|
||||
{detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Pushed to bottom, aligned with poster bottom */}
|
||||
<div className="flex gap-4 shrink-0">
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)}
|
||||
onClick={() => console.log("Play Game", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_PLAY"
|
||||
>
|
||||
{(focused) => (
|
||||
<button className={`flex items-center gap-3 px-8 py-4 rounded-full font-black uppercase tracking-tighter transition-all duration-300 shadow-xl ${focused ? 'bg-[#2563eb] text-white scale-105 shadow-[#2563eb]/40' : 'bg-white text-black hover:bg-white/90'}`}>
|
||||
<button className={`flex items-center gap-3 px-8 h-[54px] rounded-[12px] font-black uppercase tracking-tighter transition-all duration-300 shadow-xl ${focused ? 'bg-[#2563eb] text-white scale-105 shadow-[#2563eb]/40' : 'bg-white text-black hover:bg-white/90'}`}>
|
||||
<span className="material-symbols-outlined filled">play_arrow</span>
|
||||
Start Game
|
||||
</button>
|
||||
@@ -513,26 +573,84 @@ export const GamesPage = () => {
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
|
||||
onClick={() => console.log("Download", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_DOWNLOAD"
|
||||
>
|
||||
{(focused) => (
|
||||
<button className={`flex items-center gap-3 px-8 py-4 rounded-full font-black uppercase tracking-tighter border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<button className={`flex items-center gap-3 px-8 h-[54px] rounded-[12px] font-black uppercase tracking-tighter border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => {
|
||||
if (detailsQuery.data) {
|
||||
favoriteMutation.mutate({
|
||||
gameId: detailsQuery.data.id,
|
||||
favorite: !detailsQuery.data.favorite
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (detailsQuery.data) {
|
||||
favoriteMutation.mutate({
|
||||
gameId: detailsQuery.data.id,
|
||||
favorite: !detailsQuery.data.favorite
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_FAVORITE"
|
||||
>
|
||||
{(focused) => (
|
||||
<button
|
||||
title="Favorite"
|
||||
className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'} ${detailsQuery.data?.favorite ? '!border-[#2563eb] !text-[#2563eb] !bg-[#2563eb]/10' : ''}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined ${detailsQuery.data?.favorite ? 'filled' : ''}`} style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}>
|
||||
favorite
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
<FocusableItem
|
||||
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
||||
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_COLLECTION"
|
||||
>
|
||||
{(focused) => (
|
||||
<button title="Add to Collection" className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">library_add</span>
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
{detailsQuery.data?.manualUrl && (
|
||||
<FocusableItem
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
|
||||
onClick={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_MANUAL"
|
||||
>
|
||||
{(focused) => (
|
||||
<button title="Open Manual" className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">menu_book</span>
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<section>
|
||||
<h3 className="text-[10px] font-black text-[#2563eb] uppercase tracking-[0.4em] mb-4">Brief Summary</h3>
|
||||
<p className="text-white/60 text-sm leading-relaxed font-medium">
|
||||
{detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,6 +671,22 @@ export const GamesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
{/* Collection Management Modal Overlay */}
|
||||
{isCollectionModalOpen && detailsQuery.data && (
|
||||
<CollectionModal
|
||||
collections={collectionsQuery.data || []}
|
||||
onClose={() => setIsCollectionModalOpen(false)}
|
||||
onSelect={(col) => addToCollectionMutation.mutate({
|
||||
collectionId: col.id,
|
||||
gameId: detailsQuery.data!.id,
|
||||
name: col.name
|
||||
})}
|
||||
onCreate={(name) => createCollectionMutation.mutate({
|
||||
name,
|
||||
gameId: detailsQuery.data!.id
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,6 @@ export const Sidebar = () => {
|
||||
<NavItem path="/" icon="home" label="Home" autoFocus />
|
||||
<NavItem path="/games" icon="sports_esports" label="Games" />
|
||||
<NavItem path="/collections" icon="library_books" label="Collections" />
|
||||
<NavItem path="/console" icon="videogame_asset" label="Console" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 pt-6">
|
||||
|
||||
112
src/components/VirtualKeyboard.tsx
Normal file
112
src/components/VirtualKeyboard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFocusable, FocusContext, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
interface VirtualKeyboardProps {
|
||||
onKeyPress: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Key = ({ label, onKeyPress, width = 'w-12' }: { label: string, onKeyPress: (key: string) => void, width?: string }) => {
|
||||
const { ref, focused } = useFocusable({
|
||||
onEnterPress: () => onKeyPress(label),
|
||||
});
|
||||
|
||||
const displayLabel = label === 'BACKSPACE' ? '⌫' : label === 'SPACE' ? 'SPACE' : label === 'ENTER' ? 'ENTER' : label;
|
||||
const isAction = ['BACKSPACE', 'SPACE', 'ENTER'].includes(label);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={() => onKeyPress(label)}
|
||||
className={`${width} h-12 flex items-center justify-center rounded-[10px] border transition-all duration-200 cursor-pointer ${
|
||||
focused
|
||||
? 'bg-[#2563eb] border-[#2563eb] text-white scale-110 shadow-[0_0_20px_rgba(37,99,235,0.4)] z-10'
|
||||
: 'bg-white/5 border-white/5 text-white/40 hover:bg-white/10 hover:border-white/10'
|
||||
} ${isAction ? 'px-4 flex-1' : ''}`}
|
||||
>
|
||||
<span className={`font-bold ${isAction ? 'text-[10px] tracking-widest' : 'text-sm'}`}>{displayLabel}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyboardActionButton = ({ onClick, children, className }: { onClick: () => void, children: React.ReactNode, className: string }) => {
|
||||
const { ref, focused } = useFocusable({
|
||||
onEnterPress: onClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={`${className} ${focused ? 'bg-white/20 text-white ring-2 ring-white/20 scale-105' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const VirtualKeyboard = ({ onKeyPress, onClose }: VirtualKeyboardProps) => {
|
||||
const { mode } = useInputMode();
|
||||
const { ref, focusKey } = useFocusable({
|
||||
isFocusBoundary: true,
|
||||
focusKey: 'VIRTUAL_KEYBOARD_ZONE'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'gamepad') {
|
||||
const timer = setTimeout(() => {
|
||||
setFocus('VIRTUAL_KEYBOARD_ZONE');
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const rows = [
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
|
||||
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
|
||||
['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', '.'],
|
||||
['Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '!', '?'],
|
||||
['BACKSPACE', 'SPACE', 'ENTER']
|
||||
];
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-6 p-6 bg-white/[0.02] border border-white/5 rounded-[24px] backdrop-blur-sm animate-in fade-in zoom-in-95 duration-500"
|
||||
>
|
||||
<div className="space-y-1.5 max-w-[540px] mx-auto">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex gap-1.5 justify-center">
|
||||
{row.map(key => {
|
||||
let width = 'w-11';
|
||||
if (key === 'BACKSPACE') width = 'w-[124px]';
|
||||
if (key === 'SPACE') width = 'w-[180px]';
|
||||
if (key === 'ENTER') width = 'w-[124px]';
|
||||
|
||||
return (
|
||||
<Key
|
||||
key={key}
|
||||
label={key}
|
||||
onKeyPress={onKeyPress}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<KeyboardActionButton
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 rounded-full bg-white/5 text-white/20 text-[10px] font-bold uppercase tracking-[0.2em] transition-all"
|
||||
>Hide Keyboard</KeyboardActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user