feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings

This commit is contained in:
roormonger
2026-03-23 15:31:41 -04:00
commit 9e8f148a10
40 changed files with 9935 additions and 0 deletions

53
src/App.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { Sidebar } from './components/Sidebar';
import { TopNav } from './components/TopNav';
import LibraryGrid from './components/LibraryGrid';
import { Login } from './components/Login';
import { Settings } from './components/Settings';
import { AuthProvider, useAuth } from './context/AuthContext';
import { useGamepad } from './hooks/useGamepad';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const MainLayout = () => {
return (
<div className="min-h-screen text-foreground selection:bg-[#2563eb]/30 overflow-hidden bg-[#10131f]">
<Sidebar />
<TopNav />
<main className="ml-64 pt-16 h-screen overflow-y-auto no-scrollbar">
<Outlet />
</main>
</div>
);
};
function App() {
useGamepad();
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<LibraryGrid />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</AuthProvider>
);
}
export default App;

182
src/api/client.ts Normal file
View File

@@ -0,0 +1,182 @@
export interface Game {
id: string;
title: string;
system: string;
coverUrl: string;
size: number;
}
export interface RommCollection {
id: string;
name: string;
ownerRole: 'admin' | 'user';
games: Game[];
coverUrl?: string; // RomM collections can have intrinsic covers
}
export interface UserProfile {
id: string;
username: string;
avatarUrl?: string;
roleName: string;
}
// Function to safely extract base URL
const getBaseUrl = () => {
const rawUrl = import.meta.env.VITE_ROMM_BASE_URL || 'http://localhost:8080';
const cleanUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
return cleanUrl;
};
// Function to handle external URLs directly mapped from SteamGrid or IGDB dynamically
const getFullImageUrl = (urlPath: string | undefined): string | undefined => {
if (!urlPath) return undefined;
if (urlPath.startsWith('http://') || urlPath.startsWith('https://') || urlPath.startsWith('//')) {
return urlPath;
}
const base = getBaseUrl();
if (urlPath.startsWith('/') && base.endsWith('/')) {
return `${base.slice(0, -1)}${urlPath}`;
} else if (!urlPath.startsWith('/') && !base.endsWith('/')) {
return `${base}/${urlPath}`;
}
return `${base}${urlPath}`;
};
// Map RomM's native spec to our simplified app interface
const mapRomToGame = (apiRom: any): Game => {
let coverUrl = 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400';
if (apiRom.url_cover) {
coverUrl = getFullImageUrl(apiRom.url_cover) || coverUrl;
} else if (apiRom.url_covers_large && apiRom.url_covers_large.length > 0) {
coverUrl = getFullImageUrl(apiRom.url_covers_large[0]) || coverUrl;
}
return {
id: String(apiRom.id),
title: apiRom.name || apiRom.fs_name_no_ext || 'Unknown Title',
system: apiRom.platform_display_name || apiRom.platform_slug || 'Unknown Platform',
coverUrl,
size: apiRom.fs_size_bytes || 0
};
};
export const rommApiClient = {
get apiBase() {
const cleanUrl = getBaseUrl();
return cleanUrl.endsWith('/api') ? cleanUrl : `${cleanUrl}/api`;
},
get token() {
return localStorage.getItem('romm_token');
},
get headers() {
return {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json'
};
},
async login(username: string, password: string): Promise<any> {
const data = new URLSearchParams();
data.append('username', username);
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');
const res = await fetch(`${this.apiBase}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: data
});
if (!res.ok) throw new Error('Authentication failed');
const json = await res.json();
localStorage.setItem('romm_token', json.access_token);
return json;
},
logout() {
localStorage.removeItem('romm_token');
},
async fetchGames(): Promise<Game[]> {
// We use this as a stand-in for 'Continue Playing'. Fetching last played games.
const res = await fetch(`${this.apiBase}/roms?last_played=true&limit=10`, { headers: this.headers });
if (!res.ok) throw new Error('Failed to fetch last played network data.');
const json = await res.json();
return json.items ? json.items.map(mapRomToGame) : [];
},
async fetchRecentGames(): Promise<Game[]> {
// Ordering by internal id desc is functionally identical to created_at logic natively
const res = await fetch(`${this.apiBase}/roms?limit=20&order_by=id&order_dir=desc`, { headers: this.headers });
if (!res.ok) throw new Error('Failed to fetch recents.');
const json = await res.json();
return json.items ? json.items.map(mapRomToGame) : [];
},
async fetchFavorites(): Promise<Game[]> {
const res = await fetch(`${this.apiBase}/roms?favorite=true&limit=20`, { headers: this.headers });
if (!res.ok) throw new Error('Failed to fetch favorites.');
const json = await res.json();
return json.items ? json.items.map(mapRomToGame) : [];
},
async fetchCollections(): Promise<RommCollection[]> {
const res = await fetch(`${this.apiBase}/collections`, { headers: this.headers });
if (!res.ok) throw new Error('Failed to fetch collections meta.');
const collections = await res.json();
// Concurrently fetch the games arrays for each populated collection to supply UI with hydration
const mapped = await Promise.all(collections.map(async (c: any) => {
let gamesItems: Game[] = [];
try {
const gamesRes = await fetch(`${this.apiBase}/roms?collection_id=${c.id}&limit=20`, { headers: this.headers });
if (gamesRes.ok) {
const gamesJson = await gamesRes.json();
gamesItems = gamesJson.items ? gamesJson.items.map(mapRomToGame) : [];
}
} catch (e) { console.error("Could not fetch collection games", e) }
const coverUrl = c.url_cover ? getFullImageUrl(c.url_cover) : undefined;
// Currently extrapolating that public featured collections represent admin
let role: "admin" | "user" = "user";
if (c.name.toLowerCase() === 'featured' && c.is_public) {
role = "admin";
}
return {
id: String(c.id),
name: c.name,
ownerRole: role,
games: gamesItems,
coverUrl
};
}));
return mapped;
},
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.');
const json = await res.json();
// RomM obfuscates user assets using hex-encoded IDs (e.g. "User:1" -> "557365723a31")
const hexId = Array.from(`User:${json.id}`).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('');
const ts = new Date().getTime(); // Auto-cache bust like official UI
const constructedAvatarUrl = `${getBaseUrl()}/assets/romm/assets/users/${hexId}/profile/avatar.png?ts=${ts}`;
return {
id: String(json.id),
username: json.username || 'Unknown',
avatarUrl: constructedAvatarUrl,
roleName: json.role?.role_name || json.role?.name || String(json.role) || 'User',
};
}
};

View File

@@ -0,0 +1,44 @@
import { MagicCard } from "./MagicCard";
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
export const CollectionCard = ({
name,
caption,
coverUrl,
icon
}: {
name: string;
caption: string;
coverUrl?: string;
icon?: string;
}) => {
const { ref, focused } = useFocusableAutoScroll({
onEnterPress: () => console.log('Opening collection', name)
});
return (
<MagicCard
ref={ref}
className={`w-56 shrink-0 cursor-pointer group flex flex-col p-3 rounded-[12px] bg-[#1c1f2c] shadow-[0_10px_30px_rgba(0,0,0,0.2)] transition-all z-10 hover:z-20 ${focused ? "scale-105 ring-2 ring-[#2563eb] z-30" : ""}`}
>
<div className="aspect-[2/3] bg-white/5 overflow-hidden mb-3 rounded-[8px] border border-white/5 shadow-inner relative flex flex-col items-center justify-center transition-colors duration-300 group-hover:bg-[#2563eb]/10">
{coverUrl ? (
<img
alt={name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
src={coverUrl}
/>
) : (
<span className="material-symbols-outlined text-[#c3c6d7]/30 text-5xl group-hover:text-[#2563eb] group-hover:scale-110 transition-all duration-500" style={{ fontVariationSettings: "'FILL' 1" }}>
{icon || 'inventory_2'}
</span>
)}
</div>
<div className="px-1 flex flex-col relative z-20">
<h4 className="text-sm font-black text-white truncate geist-mono uppercase tracking-tight">{name}</h4>
<p className="text-[10px] text-[#2563eb] uppercase geist-mono tracking-widest font-bold mt-1 opacity-80">{caption}</p>
</div>
</MagicCard>
);
};

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
import { Game } from '../api/client';
const FocusablePoster = ({ coverUrl }: { coverUrl: string }) => {
const { ref, focused } = useFocusableAutoScroll();
return (
<div ref={ref} className={`w-[400px] aspect-[3/4] shrink-0 bg-white/5 overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.3)] transition-all duration-500 ease-out ${focused ? 'scale-105 ring-4 ring-[#2563eb] shadow-2xl z-20' : ''}`}>
<img alt="Hero Poster" className="w-full h-full object-cover" src={coverUrl} />
</div>
);
};
const FocusableButton = ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => {
const { ref, focused } = useFocusableAutoScroll({ onEnterPress: () => onClick?.() });
return (
<button ref={ref} onClick={onClick} className={`${className} transition-all duration-300 ${focused ? 'scale-105 ring-2 ring-[#2563eb] shadow-xl z-10' : ''}`}>
{children}
</button>
);
};
const FocusableThumb = ({ g, isActive, onClick }: { g: Game, isActive: boolean, onClick: () => void }) => {
const { ref, focused } = useFocusableAutoScroll({ onEnterPress: onClick });
return (
<div
ref={ref}
onClick={onClick}
className={`carousel-thumb w-[90px] shrink-0 aspect-[2/3] overflow-hidden cursor-pointer transition-all duration-300 ring-1 ring-white/10 bg-white/5 rounded-[6px] ${
isActive ? 'opacity-100 ring-2 ring-[#2563eb]' : 'opacity-60 hover:opacity-100'
} ${focused ? 'scale-110 ring-2 ring-white z-10 shadow-2xl' : ''}`}
>
<img className="w-full h-full object-cover" src={g.coverUrl} alt="Thumbnail poster" />
</div>
);
};
export const FeaturedHero = ({ games, isFallback }: { games: Game[], isFallback?: boolean }) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
if (isPaused || !games?.length) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % games.length);
}, 5000);
return () => clearInterval(interval);
}, [games, isPaused]);
if (!games?.length) return null;
const game = games[currentIndex];
const formattedSize = (game.size / (1024 * 1024)).toFixed(2) + " MB";
return (
<section
className="bg-[#10131f] border-b border-white/5"
id="featured-section"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<div className="px-12 py-12">
<div className="flex gap-12 items-stretch max-w-full">
{/* Large Hero Poster */}
<FocusablePoster coverUrl={game.coverUrl} />
{/* Content Area */}
<div className="flex-1 flex flex-col justify-between min-w-0">
<div className="flex flex-col min-w-0">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-[#2563eb]/20 text-[#b4c5ff] mb-4 w-fit border border-[#2563eb]/30 rounded-md">
<span className="material-symbols-outlined text-[10px]" style={{ fontVariationSettings: "'FILL' 1" }}>
{isFallback ? 'history' : 'star'}
</span>
<span className="text-[10px] font-black uppercase tracking-widest geist-mono">
{isFallback ? 'Recently Added' : 'Featured'}
</span>
</div>
<h2
className="text-[72px] font-black text-white tracking-tighter mb-4 leading-[0.9] uppercase geist-mono truncate"
title={game.title}
>
{game.title}
</h2>
<div className="flex gap-10 mb-8 geist-mono">
<div className="flex flex-col">
<span className="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Platform</span>
<span className="text-xs font-black text-white">{game.system}</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Size</span>
<span className="text-xs font-black text-white">{formattedSize}</span>
</div>
</div>
<p className="text-sm text-[#c3c6d7] leading-relaxed max-w-2xl mb-8 geist-mono">
Experience the magic of {game.title} perfectly emulated and synced natively by the Digital Atelier architecture.
</p>
<div className="flex items-center gap-3 mb-8">
<FocusableButton className="bg-gradient-to-br from-[#2563eb] to-[#6001d1] hover:opacity-90 text-white h-11 px-8 font-black flex items-center gap-2 active:scale-95 text-[11px] uppercase geist-mono rounded-[12px] shadow-[0_10px_30px_rgba(37,99,235,0.1)]">
<span className="material-symbols-outlined text-base" style={{ fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
Play Now
</FocusableButton>
<FocusableButton className="bg-transparent hover:bg-white/10 text-[#2563eb] border border-white/5 h-11 px-8 font-black flex items-center gap-2 text-[11px] uppercase geist-mono rounded-[12px]">
<span className="material-symbols-outlined text-base">download</span>
Download
</FocusableButton>
</div>
</div>
{/* Thumbnail Carousel */}
<div className="relative w-full overflow-hidden mt-4">
<div className="flex gap-3 overflow-x-auto no-scrollbar scroll-smooth py-2 px-1 -mx-1 -my-2">
{games.map((g, i) => (
<FocusableThumb
key={g.id}
g={g}
isActive={i === currentIndex}
onClick={() => setCurrentIndex(i)}
/>
))}
</div>
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,31 @@
import { MagicCard } from "./MagicCard";
import { syncToPC } from "../utils/sync";
import { Game } from "../api/client";
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
export const GameCard = ({ game }: { game: Game }) => {
const { ref, focused } = useFocusableAutoScroll({
onEnterPress: () => syncToPC(game.id, game.title)
});
return (
<MagicCard
ref={ref}
className={`w-56 shrink-0 cursor-pointer group flex flex-col p-3 rounded-[12px] bg-[#1c1f2c] shadow-[0_10px_30px_rgba(0,0,0,0.2)] transition-all z-10 hover:z-20 ${focused ? "scale-105 ring-2 ring-[#2563eb] z-30" : ""}`}
onClick={() => syncToPC(game.id, game.title)}
>
<div className="aspect-[2/3] bg-white/5 overflow-hidden mb-3 rounded-[8px] border border-white/5 shadow-inner">
<img
alt={game.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
src={game.coverUrl}
/>
</div>
<div className="px-1 flex flex-col relative z-20">
<h4 className="text-sm font-black text-white truncate geist-mono uppercase tracking-tight">{game.title}</h4>
<p className="text-[10px] text-[#2563eb] uppercase geist-mono tracking-widest font-bold mt-1 opacity-80">{game.system}</p>
</div>
</MagicCard>
);
};

View File

@@ -0,0 +1,185 @@
import { useRef } from 'react';
import { useQueries } from '@tanstack/react-query';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
import { rommApiClient, Game } from '../api/client';
import { GameCard } from './GameCard';
import { CollectionCard } from './CollectionCard';
import { FeaturedHero } from './FeaturedHero';
const ScrollBumper = ({ children }: { children: React.ReactNode }) => (
<div className="snap-start shrink-0 px-3 py-6">
{children}
</div>
);
const ScrollableSection = ({ title, children, showViewAll }: { title: string, children: React.ReactNode, showViewAll?: boolean }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const scroll = (direction: 'left' | 'right') => {
if (scrollRef.current) {
const { current } = scrollRef;
const scrollAmount = direction === 'left' ? -current.offsetWidth * 0.75 : current.offsetWidth * 0.75;
current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
};
return (
<section className="relative group/section">
<div className="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
<div className="flex items-center gap-4">
<h3 className="text-xl font-black text-white uppercase tracking-tighter geist-mono">{title}</h3>
<div className="flex items-center gap-1 opacity-60 hover:opacity-100 transition-opacity duration-300">
<button onClick={() => scroll('left')} className="hover:text-white text-[#c3c6d7] transition-all h-6 w-6 flex items-center justify-center rounded-full hover:bg-white/10 active:scale-95 border border-transparent hover:border-white/20">
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'wght' 300" }}>chevron_left</span>
</button>
<button onClick={() => scroll('right')} className="hover:text-white text-[#c3c6d7] transition-all h-6 w-6 flex items-center justify-center rounded-full hover:bg-white/10 active:scale-95 border border-transparent hover:border-white/20">
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'wght' 300" }}>chevron_right</span>
</button>
</div>
</div>
{showViewAll && <a className="text-[10px] font-black text-[#2563eb] hover:underline uppercase geist-mono tracking-[0.3em]" href="#">View All</a>}
</div>
<div ref={scrollRef} className="flex overflow-x-auto no-scrollbar -mx-3 scroll-smooth relative pt-2 pb-4">
{children}
</div>
</section>
);
};
const FocusableContinueCard = ({ game }: { game: Game }) => {
const { ref, focused } = useFocusableAutoScroll({ onEnterPress: () => console.log("Continue Game", game.id) });
return (
<ScrollBumper>
<div ref={ref} className={`bg-[#1c1f2c] p-6 flex gap-6 hover:bg-[#272937] transition-all duration-300 cursor-pointer group rounded-[12px] ring-1 w-[420px] shrink-0 shadow-[0_10px_30px_rgba(0,0,0,0.2)] ${focused ? 'scale-105 ring-2 ring-[#2563eb] bg-[#272937] z-10 shadow-2xl' : 'ring-white/5 hover:ring-[#2563eb]/50'}`}>
<div className="w-28 aspect-square bg-white/5 overflow-hidden shrink-0 rounded-[8px] border border-white/5 shadow-inner">
<img className={`w-full h-full object-cover transition-transform duration-700 ${focused ? 'scale-105' : 'group-hover:scale-105'}`} src={game.coverUrl} alt="Cover" />
</div>
<div className="flex-1 flex flex-col justify-center min-w-0">
<h4 className="text-sm font-black text-white mb-3 geist-mono uppercase line-clamp-2 truncate whitespace-normal">{game.title}</h4>
<div className="flex items-center justify-between text-[10px] text-[#c3c6d7] uppercase geist-mono mb-2 font-bold tracking-widest">
<span>84% Complete</span>
<span className="text-[#2563eb]">Active</span>
</div>
<div className="h-1 bg-white/5 overflow-hidden rounded-full mt-auto">
<div className="h-full bg-[#2563eb] w-[84%] shadow-[0_0_10px_rgba(37,99,235,0.8)]"></div>
</div>
</div>
</div>
</ScrollBumper>
);
};
const LibraryGrid = () => {
const { ref: gridRef, focusKey: gridFocusKey } = useFocusable();
const results = useQueries({
queries: [
{ queryKey: ['recentGames'], queryFn: () => rommApiClient.fetchRecentGames() },
{ queryKey: ['collections'], queryFn: () => rommApiClient.fetchCollections() },
{ queryKey: ['favorites'], queryFn: () => rommApiClient.fetchFavorites() },
{ queryKey: ['playing'], queryFn: () => rommApiClient.fetchGames() }
]
});
const isLoading = results.some(r => r.isLoading);
const error = results.some(r => r.error);
if (isLoading) return <div className="text-center py-20 text-white/50 animate-pulse col-span-full geist-mono uppercase">Loading vault data...</div>;
if (error) return <div className="text-red-500 col-span-full geist-mono uppercase">Failed to load vault data</div>;
const recentGames = results[0].data || [];
const collections = results[1].data || [];
const favorites = results[2].data || [];
const continuePlaying = results[3].data || [];
const featuredCollections = collections.filter((c) => c.name.toLowerCase() === 'featured');
const combinedFeaturedGames = Array.from(new Map(featuredCollections.flatMap(c => c.games).map(g => [g.id, g])).values());
const hasFeatured = combinedFeaturedGames.length > 0;
const heroGames = hasFeatured ? combinedFeaturedGames : recentGames;
const showRecentSection = hasFeatured;
const standardCollections = collections.filter(c => c.name.toLowerCase() !== 'featured');
return (
<FocusContext.Provider value={gridFocusKey}>
<div ref={gridRef} className="flex flex-col relative w-full h-full">
{heroGames.length > 0 && <FeaturedHero games={heroGames} isFallback={!hasFeatured} />}
<div className="px-12 py-16 space-y-20 pb-24">
{showRecentSection && recentGames.length > 0 && (
<ScrollableSection title="Recently Added" showViewAll>
{recentGames.map((game) => (
<ScrollBumper key={game.id + "-recent"}>
<GameCard game={game} />
</ScrollBumper>
))}
</ScrollableSection>
)}
{continuePlaying.length > 0 && (
<ScrollableSection title="Continue Playing">
{continuePlaying.slice(0, 5).map((game) => (
<FocusableContinueCard key={game.id + "-continue"} game={game} />
))}
</ScrollableSection>
)}
{favorites.length > 0 && (
<ScrollableSection title="Favorites" showViewAll>
{favorites.map((game) => (
<ScrollBumper key={game.id + "-fav"}>
<GameCard game={game} />
</ScrollBumper>
))}
</ScrollableSection>
)}
{standardCollections.length > 0 && (
<ScrollableSection title="Collections" showViewAll>
{standardCollections.map((col) => {
const cover = col.games?.[0]?.coverUrl || '';
const gamesCount = col.games?.length || 0;
const caption = gamesCount > 0 ? `${gamesCount} Game${gamesCount === 1 ? '' : 's'}` : (col.ownerRole === 'admin' ? 'Public' : 'Private');
return (
<ScrollBumper key={col.id}>
<CollectionCard
name={col.name}
caption={caption}
coverUrl={cover}
/>
</ScrollBumper>
);
})}
</ScrollableSection>
)}
{/* Autogenerated Collections */}
<ScrollableSection title="Autogenerated Collections">
{[
{ id: 'auto-1', name: 'All Games', icon: 'apps' },
{ id: 'auto-2', name: 'Favorites', icon: 'star' },
{ id: 'auto-3', name: 'Recently Added', icon: 'schedule' },
{ id: 'auto-4', name: 'Recently Played', icon: 'history' },
{ id: 'auto-5', name: 'Most Played', icon: 'leaderboard' },
{ id: 'auto-6', name: 'Never Played', icon: 'videogame_asset_off' },
].map((col) => (
<ScrollBumper key={col.id}>
<CollectionCard
name={col.name}
caption="Smart Collection"
icon={col.icon}
/>
</ScrollBumper>
))}
</ScrollableSection>
</div>
</div>
</FocusContext.Provider>
);
};
export default LibraryGrid;

93
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Input, Button } from '@heroui/react';
import { MagicCard } from './MagicCard';
import { useAuth } from '../context/AuthContext';
export const Login = () => {
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (username.length > 0 && password.length > 0) {
setIsLoading(true);
setError(false);
const success = await login(username, password);
if (!success) {
setError(true);
}
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-[#10131f] flex text-foreground selection:bg-[#2563eb]/30 overflow-hidden relative items-center justify-center">
{/* Background radial gradient glow for atmospheric depth */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-[#2563eb]/10 blur-[100px] pointer-events-none rounded-full" />
<MagicCard className="w-full max-w-sm rounded-[1.5rem] bg-[#1c1f2c] shadow-[0_20px_50px_rgba(0,0,0,0.3)] ring-1 ring-white/5 z-10 mx-4">
<form onSubmit={handleLogin} className="p-10 flex flex-col gap-8 items-center bg-transparent relative z-20">
<div className="flex flex-col items-center gap-2 mb-2 w-full text-center">
<h1 className="text-2xl font-black text-white tracking-tighter uppercase geist-mono">{import.meta.env.VITE_LIBRARY_TITLE || "Romm"}</h1>
<p className="text-[10px] geist-mono uppercase tracking-[0.05em] text-[#c3c6d7] opacity-60">{import.meta.env.VITE_LIBRARY_SUBTITLE || "Game Manager"}</p>
</div>
<div className="flex flex-col gap-4 w-full">
<Input
autoFocus
classNames={{
inputWrapper: "bg-white/5 hover:bg-white/10 group-data-[focus=true]:bg-white/10 focus-within:!bg-white/10 border-none shadow-none ring-0 group-data-[focus=true]:ring-1 group-data-[focus=true]:ring-[#2563eb]/50 transition-all rounded-[0.5rem] h-12",
input: "text-white geist-mono text-sm placeholder:text-[#c3c6d7]/50"
}}
placeholder="Archivist ID"
startContent={<span className="material-symbols-outlined text-[#c3c6d7] text-sm pr-2">person</span>}
value={username}
onValueChange={setUsername}
/>
<Input
type="password"
classNames={{
inputWrapper: "bg-white/5 hover:bg-white/10 group-data-[focus=true]:bg-white/10 focus-within:!bg-white/10 border-none shadow-none ring-0 group-data-[focus=true]:ring-1 group-data-[focus=true]:ring-[#2563eb]/50 transition-all rounded-[0.5rem] h-12",
input: "text-white font-sans text-sm tracking-[0.3em] placeholder:text-[#c3c6d7]/50 placeholder:tracking-normal"
}}
placeholder="Passcode"
startContent={<span className="material-symbols-outlined text-[#c3c6d7] text-sm pr-2">lock</span>}
value={password}
onValueChange={setPassword}
/>
{error && (
<p className="text-red-500 text-[10px] text-center geist-mono uppercase tracking-widest font-bold mt-2 animate-appearance-in">Invalid credentials</p>
)}
</div>
<Button
type="submit"
isLoading={isLoading}
className="w-full h-12 bg-gradient-to-br from-[#2563eb] to-[#6001d1] hover:opacity-90 text-white font-black flex items-center justify-center gap-2 active:scale-95 transition-all text-[11px] uppercase geist-mono rounded-[3rem] shadow-[0_10px_30px_rgba(37,99,235,0.1)] mt-2"
>
{!isLoading && <>Access Vault <span className="material-symbols-outlined text-base" style={{ fontVariationSettings: "'wght' 600" }}>login</span></>}
</Button>
<div className="flex w-full items-center gap-4 my-2 opacity-50">
<div className="h-[1px] flex-1 bg-gradient-to-r from-transparent via-[#c3c6d7] to-transparent"></div>
<span className="text-[10px] uppercase tracking-widest font-bold geist-mono text-[#c3c6d7]">Or</span>
<div className="h-[1px] flex-1 bg-gradient-to-r from-transparent via-[#c3c6d7] to-transparent"></div>
</div>
<Button
as="a"
href={`${import.meta.env.VITE_ROMM_BASE_URL || 'http://localhost:8080'}/api/login/openid`}
className="w-full h-12 bg-white/5 hover:bg-white/10 text-white border border-white/5 font-black flex items-center justify-center gap-2 active:scale-95 transition-all text-[11px] uppercase geist-mono rounded-[3rem]"
>
<span className="material-symbols-outlined text-base">vpn_key</span>
Log in with Single Sign-On
</Button>
</form>
</MagicCard>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import React, { useRef, useState, useImperativeHandle } from "react";
import { motion } from "framer-motion";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export interface MagicCardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
}
export const MagicCard = React.forwardRef<HTMLDivElement, MagicCardProps>(
({ children, className = "", ...props }, ref) => {
const divRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [opacity, setOpacity] = useState(0);
useImperativeHandle(ref, () => divRef.current as HTMLDivElement);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!divRef.current || isFocused) return;
const div = divRef.current;
const rect = div.getBoundingClientRect();
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
const handleFocus = () => {
setIsFocused(true);
setOpacity(1);
};
const handleBlur = () => {
setIsFocused(false);
setOpacity(0);
};
const handleMouseEnter = () => setOpacity(1);
const handleMouseLeave = () => setOpacity(0);
return (
<div
ref={divRef}
onMouseMove={handleMouseMove}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
className={cn(
"relative overflow-hidden group/card bg-neutral-900",
className
)}
>
<motion.div
className="pointer-events-none absolute -inset-px rounded-xl opacity-0 transition duration-300 group-hover/card:opacity-100"
style={{
opacity,
background: `radial-gradient(400px circle at ${position.x}px ${position.y}px, rgba(255,255,255,0.1), transparent 40%)`,
}}
/>
{children}
</div>
);
}
);

View File

@@ -0,0 +1,83 @@
export const Settings = () => {
return (
<div className="px-8 pb-12 pt-8">
<div className="max-w-5xl mx-auto">
{/* Page Header */}
<header className="mb-10">
<h1 className="text-4xl font-extrabold tracking-tight text-[#e0e1f4] mb-2">Vault Console</h1>
<p className="text-[#c3c6d7] font-medium">Configure your library experience and server connections.</p>
</header>
{/* Horizontal Tab Bar */}
<div className="flex items-center gap-2 mb-10 overflow-x-auto hide-scrollbar bg-[#181b28] p-1.5 rounded-full w-fit">
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Profile</button>
<button className="px-6 py-2.5 rounded-full text-sm font-bold bg-[#2563eb] text-[#eeefff] shadow-lg shadow-[#2563eb]/20 transition-all">User Interface</button>
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Library Management</button>
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Metadata Sources</button>
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Administration</button>
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all whitespace-nowrap">Server Stats</button>
</div>
<div className="flex flex-col gap-8">
<section className="w-full space-y-6">
<div className="bg-[#272937] rounded-lg p-8 shadow-xl">
<div className="flex items-center gap-3 mb-8">
<span className="material-symbols-outlined text-[#b4c5ff]" style={{ fontVariationSettings: "'FILL' 1" }}>palette</span>
<h2 className="text-xl font-bold tracking-tight text-[#e0e1f4]">Visual Identity</h2>
</div>
<div className="space-y-8">
{/* Toggle: Dark Mode */}
<div className="flex items-center justify-between group">
<div>
<p className="font-bold text-[#e0e1f4]">Enable Dark Mode</p>
<p className="text-sm text-[#c3c6d7]">Switch between obsidian and slate themes.</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input defaultChecked className="sr-only peer" type="checkbox" />
<div className="w-12 h-6 bg-[#363847] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#2563eb]"></div>
</label>
</div>
{/* Toggle: Notifications */}
<div className="flex items-center justify-between group">
<div>
<p className="font-bold text-[#e0e1f4]">Show Notifications</p>
<p className="text-sm text-[#c3c6d7]">Get alerts for scan completions and updates.</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input className="sr-only peer" type="checkbox" />
<div className="w-12 h-6 bg-[#363847] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#2563eb]"></div>
</label>
</div>
{/* Toggle: Glassmorphism */}
<div className="flex items-center justify-between group">
<div>
<p className="font-bold text-[#e0e1f4]">Glassmorphism Effects</p>
<p className="text-sm text-[#c3c6d7]">Enable translucent blurs and layered depth.</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input defaultChecked className="sr-only peer" type="checkbox" />
<div className="w-12 h-6 bg-[#363847] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#2563eb]"></div>
</label>
</div>
</div>
</div>
{/* Layout Preview Card */}
<div className="bg-gradient-to-br from-[#2563eb]/20 to-[#6001d1]/20 rounded-lg p-8 border border-white/5 relative overflow-hidden group">
<div className="relative z-10">
<h3 className="text-lg font-bold text-white mb-2">Interface Preview</h3>
<p className="text-sm text-blue-200/70 mb-6">Current: Ultra-Modern / Editorial</p>
<div className="flex gap-2">
<div className="w-12 h-2 bg-white/20 rounded-full"></div>
<div className="w-24 h-2 bg-white/40 rounded-full"></div>
<div className="w-8 h-2 bg-white/20 rounded-full"></div>
</div>
</div>
<span className="material-symbols-outlined absolute -right-4 -bottom-4 text-9xl text-white/5 rotate-12 transition-transform group-hover:rotate-0 duration-700">dashboard_customize</span>
</div>
</section>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { rommApiClient } from '../api/client';
import { FocusContext } from '@noriginmedia/norigin-spatial-navigation';
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
export const Sidebar = () => {
const { logout } = useAuth();
const { data: user } = useQuery({ queryKey: ['currentUser'], queryFn: () => rommApiClient.fetchCurrentUser() });
const [imgError, setImgError] = useState(false);
const location = useLocation();
const navigate = useNavigate();
// Create physical bounding box for Sidebar natively mapping navigation paths
const { ref: containerRef, focusKey: sidebarFocusKey } = useFocusableAutoScroll();
const fallbackName = user?.username || "Admin";
const avatarFallback = `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackName)}&background=2563eb&color=fff&bold=true`;
const avatarUrl = !imgError && user?.avatarUrl ? user.avatarUrl : avatarFallback;
const NavItem = ({ path, icon, label, autoFocus }: { path: string, icon: string, label: string, autoFocus?: boolean }) => {
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
const { ref, focused, focusSelf } = useFocusableAutoScroll({
onEnterPress: () => navigate(path),
});
useEffect(() => {
if (autoFocus) {
focusSelf();
}
}, [autoFocus, focusSelf]);
return (
<Link
to={path}
ref={ref}
className={`mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95 rounded-[12px]
${isActive
? "bg-[#2563eb]/20 text-[#2563eb]"
: "text-[#c3c6d7] hover:text-white hover:bg-white/5"
} ${focused ? "scale-105 ring-2 ring-[#2563eb] bg-white/10 z-10 shadow-lg" : ""}`}
>
<span className="material-symbols-outlined" style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}>{icon}</span>
<span className="geist-mono tracking-tight text-sm uppercase">{label}</span>
</Link>
);
};
return (
<FocusContext.Provider value={sidebarFocusKey}>
<aside ref={containerRef} className="w-64 h-screen fixed left-0 top-0 bg-[#181b28] shadow-[20px_0_50px_rgba(0,0,0,0.3)] flex flex-col py-8 z-[60]">
<div className="px-6 mb-10">
<h1 className="text-xl font-black text-white tracking-tighter uppercase geist-mono">{import.meta.env.VITE_LIBRARY_TITLE || "Romm"}</h1>
<p className="text-[10px] geist-mono uppercase tracking-[0.05em] text-[#c3c6d7] opacity-60">{import.meta.env.VITE_LIBRARY_SUBTITLE || "Game Manager"}</p>
</div>
<nav className="flex-1 flex flex-col gap-1">
<NavItem path="/" icon="home" label="Home" autoFocus />
<NavItem path="/platforms" icon="bookmarks" label="Platforms" />
<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">
<NavItem path="/settings" icon="settings" label="Settings" />
<div className="px-6 mt-4 flex items-center gap-3 overflow-hidden">
<div className="w-8 h-8 rounded-full bg-white/10 overflow-hidden shrink-0 ring-1 ring-white/20">
<img
alt="User profile avatar"
className="w-full h-full object-cover"
src={avatarUrl}
onError={() => setImgError(true)}
/>
</div>
<div className="flex flex-col leading-none flex-1 min-w-0">
<span className="text-xs font-bold text-white geist-mono truncate">{user?.username || "Loading"}</span>
<span className="text-[10px] text-[#2563eb] font-bold uppercase tracking-widest geist-mono truncate">{user?.roleName || "..."}</span>
</div>
<button onClick={logout} className="text-[#c3c6d7] hover:text-white hover:bg-red-500/20 p-2 shrink-0 rounded-full transition-all active:scale-95 flex items-center justify-center" title="Log Out">
<span className="material-symbols-outlined text-sm">logout</span>
</button>
</div>
</div>
</aside>
</FocusContext.Provider>
);
};

51
src/components/TopNav.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { useInputMode } from '../context/InputModeContext';
export const TopNav = () => {
const { mode } = useInputMode();
return (
<header className="fixed top-0 right-0 left-64 h-16 z-50 bg-[#10131f]/80 backdrop-blur-2xl flex items-center justify-between px-12 border-b border-white/5">
<div className="flex items-center gap-6 w-full">
<div className="relative flex-1 max-w-md focus-within:ring-1 focus-within:ring-[#2563eb]/50 rounded-full overflow-hidden bg-[#070913] border border-white/5 transition-all outline-none shadow-inner">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-[#c3c6d7] text-sm">search</span>
<input className="w-full bg-transparent border-none pl-10 pr-4 py-2 text-[11px] font-bold tracking-widest uppercase text-white placeholder-[#c3c6d7]/50 focus:ring-0 geist-mono outline-none" placeholder="Search the vault..." type="text"/>
</div>
<div className="hidden xl:flex items-center gap-6 ml-4 geist-mono justify-end flex-1">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="account_tree">account_tree</span>
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">12</span> Platforms</span>
</div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="sports_esports">sports_esports</span>
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">1,402</span> Games</span>
</div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="hard_drive">hard_drive</span>
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">2.4 TB</span></span>
</div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="save">save</span>
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">842</span> Saves</span>
</div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="history">history</span>
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">142</span> States</span>
</div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="photo_library">photo_library</span>
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">5,103</span> Screens</span>
</div>
<div className="w-px h-4 bg-white/10 mx-2"></div>
{/* 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'}>
<span className="material-symbols-outlined text-[16px]" style={{ fontVariationSettings: "'FILL' 1" }}>
{mode === 'gamepad' ? 'sports_esports' : 'mouse'}
</span>
</div>
</div>
</div>
</header>
);
};

View File

@@ -0,0 +1,48 @@
import { createContext, useContext, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { rommApiClient } from '../api/client';
interface AuthContextType {
isAuthenticated: boolean;
login: (u: string, p: string) => Promise<boolean>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState(!!rommApiClient.token);
const navigate = useNavigate();
const login = async (user: string, pass: string) => {
try {
await rommApiClient.login(user, pass);
setIsAuthenticated(true);
navigate('/');
return true;
} catch (e) {
console.error(e);
return false;
}
};
const logout = () => {
setIsAuthenticated(false);
navigate('/login');
};
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,60 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type InputMode = 'mouse' | 'gamepad';
interface InputModeContextType {
mode: InputMode;
setMode: (mode: InputMode) => void;
}
const InputModeContext = createContext<InputModeContextType | undefined>(undefined);
export const InputModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setModeState] = useState<InputMode>('mouse');
const setMode = (newMode: InputMode) => {
if (newMode !== mode) {
setModeState(newMode);
}
};
useEffect(() => {
// Spatial Engine Override
if (mode === 'mouse') {
document.body.classList.remove('gamepad-active');
} else {
document.body.classList.add('gamepad-active');
}
}, [mode]);
useEffect(() => {
const onMouseMove = () => {
setMode('mouse');
};
const onMouseDown = () => {
setMode('mouse');
};
window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mousedown', onMouseDown, { passive: true });
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mousedown', onMouseDown);
};
}, []);
return (
<InputModeContext.Provider value={{ mode, setMode }}>
{children}
</InputModeContext.Provider>
);
};
export const useInputMode = () => {
const context = useContext(InputModeContext);
if (context === undefined) {
throw new Error('useInputMode must be used within an InputModeProvider');
}
return context;
};

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useFocusable, UseFocusableConfig } from '@noriginmedia/norigin-spatial-navigation';
import { useInputMode } from '../context/InputModeContext';
export const useFocusableAutoScroll = (config?: UseFocusableConfig) => {
const result = useFocusable(config);
const { mode } = useInputMode();
useEffect(() => {
if (mode === 'gamepad' && result.focused && result.ref.current) {
result.ref.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}
}, [result.focused, mode]);
return {
...result,
focused: mode === 'gamepad' ? result.focused : false
};
};

110
src/hooks/useGamepad.ts Normal file
View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'react';
import { navigateByDirection } from '@noriginmedia/norigin-spatial-navigation';
import { useInputMode } from '../context/InputModeContext';
const BUTTON_A = 0;
const BUTTON_B = 1;
const BUTTON_DPAD_UP = 12;
const BUTTON_DPAD_DOWN = 13;
const BUTTON_DPAD_LEFT = 14;
const BUTTON_DPAD_RIGHT = 15;
const AXIS_THRESHOLD = 0.5;
const INITIAL_DELAY_MS = 400;
const REPEAT_RATE_MS = 100;
export const useGamepad = () => {
const requestRef = useRef<number>();
const lastState = useRef<Record<string, boolean>>({});
const lastFireTime = useRef<Record<string, number>>({});
const { setMode } = useInputMode();
const handleInput = (id: string, isPressed: boolean, action: () => void, repeatable = true) => {
const now = performance.now();
const wasPressed = lastState.current[id];
if (isPressed) {
setMode('gamepad');
if (!wasPressed) {
// Initial press isolated
action();
lastFireTime.current[id] = now;
lastState.current[id] = true;
} else if (repeatable) {
// Holding press calculates repeat boundaries natively
const holdTime = now - lastFireTime.current[id];
if (holdTime > INITIAL_DELAY_MS) {
action();
// Reset tracker mathematically for consecutive rapid fires
lastFireTime.current[id] = now - INITIAL_DELAY_MS + REPEAT_RATE_MS;
}
}
} else {
if (wasPressed) {
lastFireTime.current[id] = 0;
lastState.current[id] = false;
}
}
};
const dispatchEnter = () => {
const eventParams = { bubbles: true, cancelable: true };
const keydown = new window.KeyboardEvent('keydown', eventParams);
Object.defineProperty(keydown, 'keyCode', { get: () => 13 });
Object.defineProperty(keydown, 'key', { get: () => 'Enter' });
document.dispatchEvent(keydown);
window.dispatchEvent(keydown);
};
const dispatchEscape = () => {
const eventParams = { bubbles: true, cancelable: true };
const keydown = new window.KeyboardEvent('keydown', eventParams);
Object.defineProperty(keydown, 'keyCode', { get: () => 27 });
Object.defineProperty(keydown, 'key', { get: () => 'Escape' });
document.dispatchEvent(keydown);
window.dispatchEvent(keydown);
};
const checkGamepad = () => {
try {
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
// Multiplex all gamepads to defeat Ghost Virtual Devices hogs natively occupying slot 0
for (let i = 0; i < gamepads.length; i++) {
const gp = gamepads[i];
if (!gp) continue;
const up = gp.buttons[BUTTON_DPAD_UP]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] < -AXIS_THRESHOLD);
const down = gp.buttons[BUTTON_DPAD_DOWN]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] > AXIS_THRESHOLD);
const left = gp.buttons[BUTTON_DPAD_LEFT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] < -AXIS_THRESHOLD);
const right = gp.buttons[BUTTON_DPAD_RIGHT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] > AXIS_THRESHOLD);
const enter = gp.buttons[BUTTON_A]?.pressed;
const back = gp.buttons[BUTTON_B]?.pressed;
handleInput(`gp${i}_up`, up, () => navigateByDirection('up', {}));
handleInput(`gp${i}_down`, down, () => navigateByDirection('down', {}));
handleInput(`gp${i}_left`, left, () => navigateByDirection('left', {}));
handleInput(`gp${i}_right`, right, () => navigateByDirection('right', {}));
handleInput(`gp${i}_enter`, enter, () => dispatchEnter(), false);
handleInput(`gp${i}_back`, back, () => dispatchEscape(), false);
}
} catch (e) {
console.error("Gamepad Polling Error", e);
}
requestRef.current = requestAnimationFrame(checkGamepad);
};
useEffect(() => {
const onConnect = () => console.log("Gamepad Connected Native Callback!");
const onDisconnect = () => console.log("Gamepad Disconnected Native Callback!");
window.addEventListener("gamepadconnected", onConnect);
window.addEventListener("gamepaddisconnected", onDisconnect);
requestRef.current = requestAnimationFrame(checkGamepad);
return () => {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
window.removeEventListener("gamepadconnected", onConnect);
window.removeEventListener("gamepaddisconnected", onDisconnect);
};
}, []);
};

35
src/index.css Normal file
View File

@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: dark;
}
}
body {
margin: 0;
min-height: 100vh;
background-color: #10131f; /* Adjusted to Stitch's deep charcoal */
overflow: hidden; /* Prevent body scroll per Stitch design */
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
body.gamepad-active {
cursor: none !important;
}
body.gamepad-active * {
cursor: none !important;
}
.geist-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
}

42
src/main.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HeroUIProvider } from '@heroui/react'
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { BrowserRouter } from 'react-router-dom'
import { init } from '@noriginmedia/norigin-spatial-navigation'
import { InputModeProvider } from './context/InputModeContext'
import App from './App.tsx'
import './index.css'
init({
debug: false,
visualDebug: false
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // Keep offline disk cache for 24 hours for instant loading
},
},
})
const persister = createSyncStoragePersister({
storage: window.localStorage,
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<HeroUIProvider>
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
<InputModeProvider>
<App />
</InputModeProvider>
</PersistQueryClientProvider>
</HeroUIProvider>
</BrowserRouter>
</React.StrictMode>,
)

32
src/utils/sync.ts Normal file
View File

@@ -0,0 +1,32 @@
export const syncToPC = async (gameId: string, title: string) => {
try {
if (!('showDirectoryPicker' in window)) {
alert("Your browser does not support the File System Access API. Please use a modern Chromium-based browser.");
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dirHandle = await (window as any).showDirectoryPicker();
console.log(`Syncing ${title} (${gameId}) to`, dirHandle.name);
// Placeholder logic for downloading unzipped files sequentially
const fileHandle = await dirHandle.getFileHandle(`${title.replace(/[^a-z0-9]/gi, '_')}.rom`, { create: true });
const writable = await fileHandle.createWritable();
// In a real app, this would fetch the actual parts of the game
// and stream it to the writable. For now, writing a dummy buffer.
const dummyData = new TextEncoder().encode("DUMMY ROM DATA CONTENT - IMPLEMENT NATIVE FETCH HERE");
await writable.write(dummyData);
await writable.close();
console.log(`Successfully synced ${title} to PC.`);
alert(`Successfully synced ${title} to PC.`);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('User cancelled the directory picker');
} else {
console.error('Failed to sync directory:', err);
alert('Failed to sync to PC. See console for details.');
}
}
};

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly ROMM_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}