From 4e6ce29f0b13a61e9bf9e91c037f660f92653a27 Mon Sep 17 00:00:00 2001 From: roormonger <34205054+roormonger@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:08:46 -0400 Subject: [PATCH] more game page development --- src/api/client.ts | 86 +++++++++++- src/components/CollectionModal.tsx | 216 +++++++++++++++++++++++++++++ src/components/GamesPage.tsx | 190 +++++++++++++++++++++---- src/components/Sidebar.tsx | 1 - src/components/VirtualKeyboard.tsx | 112 +++++++++++++++ 5 files changed, 569 insertions(+), 36 deletions(-) create mode 100644 src/components/CollectionModal.tsx create mode 100644 src/components/VirtualKeyboard.tsx diff --git a/src/api/client.ts b/src/api/client.ts index bbf231d..c1089cf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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(); 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 { + 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 { + 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 { + // 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 { + 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 { const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers }); if (!res.ok) throw new Error('Failed to fetch user profile.'); diff --git a/src/components/CollectionModal.tsx b/src/components/CollectionModal.tsx new file mode 100644 index 0000000..dff53f1 --- /dev/null +++ b/src/components/CollectionModal.tsx @@ -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 ( +
+
+ folder +
+ {collection.name} + {focused && ( + + chevron_right + + )} +
+ ); +}; + +const HeaderButton = ({ onClick, children, className, title }: { onClick: () => void, children: React.ReactNode, className: string, title?: string }) => { + const { ref, focused } = useFocusable({ + onEnterPress: onClick, + }); + + return ( + + ); +}; + +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 ( + +
+
e.stopPropagation()} + > + {/* Header - Minimalist Atelier */} +
+
+
+

Add to Collection

+
+ +
+ 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" + > + add + New + + + + close + +
+
+ + {/* List Content */} +
+ {/* Create New Flow (Inline at top) */} + {isCreating && ( +
+
+ edit_note + setNewCollectionName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newCollectionName.trim()) { + onCreate(newCollectionName.trim()); + setNewCollectionName(''); + setIsCreating(false); + } + if (e.key === 'Escape') setIsCreating(false); + }} + /> +
+ + +
+
+
+ )} + + {isCreating && mode === 'gamepad' && ( + setIsCreating(false)} + /> + )} + +
+ {collections.map((col) => ( + onSelect(col)} + /> + ))} + + {collections.length === 0 && !isCreating && ( +
+ inventory_2 +

Archive Empty

+
+ )} +
+
+ + {/* Bottom Atelier Trim - Removed as requested */} +
+
+ + ); +}; diff --git a/src/components/GamesPage.tsx b/src/components/GamesPage.tsx index 5914411..6e87d11 100644 --- a/src/components/GamesPage.tsx +++ b/src/components/GamesPage.tsx @@ -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(null); const [selectedGameId, setSelectedGameId] = useState(null); + const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false); const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null); const detailTimeoutRef = React.useRef(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(); 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 = () => {
{detailsQuery.data ? ( -
-
+
+
{/* Poster */} -
+
{/* Info Pane */} -
-
+
+
PLAYABLE ID: ROM-{detailsQuery.data.id}
-

{detailsQuery.data.title}

+

+ {detailsQuery.data.title} +

- {/* Row 1: Regions, Release Date & Rating */} + {/* Metadata rows with consistent spacing (mb-2) */}
Region: @@ -457,7 +509,6 @@ export const GamesPage = () => { )}
- {/* Row 2: Companies & Franchises */}
Companies: @@ -475,8 +526,7 @@ export const GamesPage = () => { )}
- {/* Row 3: Genres */} -
+
Genres: @@ -485,9 +535,8 @@ export const GamesPage = () => {
- {/* Row 4: Age Rating Text */} {detailsQuery.data.esrbRating && ( -
+
Age Rating: {detailsQuery.data.esrbRating} @@ -495,15 +544,26 @@ export const GamesPage = () => {
)} -
+ {/* Summary Section - Fixed Height & Scrollable */} +
+
+

+ {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."} +

+
+
+ + {/* Action Buttons - Pushed to bottom, aligned with poster bottom */} +
{ 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) => ( - @@ -513,26 +573,84 @@ export const GamesPage = () => { { 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) => ( - )} + +
+ { 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) => ( + + )} + + + { if (detailsQuery.data) setIsCollectionModalOpen(true); }} + onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }} + onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }} + className="shrink-0" + focusKey="DETAILS_COLLECTION" + > + {(focused) => ( + + )} + + + {detailsQuery.data?.manualUrl && ( + { 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) => ( + + )} + + )} +
-
-
-

Brief Summary

-

- {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."} -

-
-
@@ -553,6 +671,22 @@ export const GamesPage = () => { )}
+ {/* Collection Management Modal Overlay */} + {isCollectionModalOpen && detailsQuery.data && ( + 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 + })} + /> + )}
); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8be4f17..ae009e6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -60,7 +60,6 @@ export const Sidebar = () => { -
diff --git a/src/components/VirtualKeyboard.tsx b/src/components/VirtualKeyboard.tsx new file mode 100644 index 0000000..a6cd3dc --- /dev/null +++ b/src/components/VirtualKeyboard.tsx @@ -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 ( +
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' : ''}`} + > + {displayLabel} +
+ ); +}; + +const KeyboardActionButton = ({ onClick, children, className }: { onClick: () => void, children: React.ReactNode, className: string }) => { + const { ref, focused } = useFocusable({ + onEnterPress: onClick, + }); + + return ( + + ); +}; + +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 ( + +
+
+ {rows.map((row, i) => ( +
+ {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 ( + + ); + })} +
+ ))} +
+ + {onClose && ( +
+ Hide Keyboard +
+ )} +
+
+ ); +};