feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings
This commit is contained in:
53
src/App.tsx
Normal file
53
src/App.tsx
Normal 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
182
src/api/client.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
};
|
||||
44
src/components/CollectionCard.tsx
Normal file
44
src/components/CollectionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
131
src/components/FeaturedHero.tsx
Normal file
131
src/components/FeaturedHero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/components/GameCard.tsx
Normal file
31
src/components/GameCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
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;
|
||||
93
src/components/Login.tsx
Normal file
93
src/components/Login.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
src/components/MagicCard.tsx
Normal file
69
src/components/MagicCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
83
src/components/Settings.tsx
Normal file
83
src/components/Settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
src/components/Sidebar.tsx
Normal file
90
src/components/Sidebar.tsx
Normal 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
51
src/components/TopNav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/context/AuthContext.tsx
Normal file
48
src/context/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
60
src/context/InputModeContext.tsx
Normal file
60
src/context/InputModeContext.tsx
Normal 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;
|
||||
};
|
||||
19
src/hooks/useFocusableAutoScroll.ts
Normal file
19
src/hooks/useFocusableAutoScroll.ts
Normal 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
110
src/hooks/useGamepad.ts
Normal 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
35
src/index.css
Normal 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
42
src/main.tsx
Normal 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
32
src/utils/sync.ts
Normal 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
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly ROMM_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user