feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings
This commit is contained in:
185
src/components/LibraryGrid.tsx
Normal file
185
src/components/LibraryGrid.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user