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

View File

@@ -0,0 +1,19 @@
{
"name": "RomM UI Dev Environment",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"forwardPorts": [5173, 8080],
"postCreateCommand": "npm install --legacy-peer-deps",
"customizations": {
"vscode": {
"settings": {
"tailwindCSS.experimental.classRegex": [
["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
},
"extensions": [
"bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint"
]
}
}
}

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# .env
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# ---- Base Node ----
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
# ---- Dependencies ----
FROM base AS deps
RUN npm install --legacy-peer-deps
# ---- Builder ----
FROM deps AS builder
COPY . .
RUN npm run build
# ---- Development ----
FROM deps AS dev
COPY . .
# Expose Vite's default dev port
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
# ---- Production ----
FROM nginx:alpine AS prod
# Copy the build output from Vite (usually output to /dist)
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
romm-ui-dev:
image: node:20-alpine
working_dir: /app
ports:
- "5173:5173"
volumes:
- .:/app
environment:
- VITE_ROMM_BASE_URL=https://retro.chieflix.com
command: >
sh -c 'npm install --legacy-peer-deps && npm run dev -- --host'

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
version: '3.8'
services:
romm-ui-dev:
build:
context: .
target: dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules # Prevents host node_modules from overwriting container's
environment:
- ROMM_BASE_URL=https://retro.chieflix.com
romm-ui-prod:
build:
context: .
target: prod
ports:
- "8000:80" # Exposed on port 8000 for local prod testing

13
get_auth_details.cjs Normal file
View File

@@ -0,0 +1,13 @@
const fs = require('fs');
const content = fs.readFileSync('romm_swagger.json');
try {
let jsonString = content.toString('utf16le');
if (jsonString.charCodeAt(0) === 0xFEFF) { jsonString = jsonString.slice(1); }
const parsed = JSON.parse(jsonString);
console.log("LOGIN_OPENID:", JSON.stringify(parsed.paths['/api/login/openid'], null, 2));
console.log("OAUTH_OPENID:", JSON.stringify(parsed.paths['/api/oauth/openid'], null, 2));
} catch (e) {
const parsed = JSON.parse(content.toString('utf8'));
console.log("LOGIN_OPENID:", JSON.stringify(parsed.paths['/api/login/openid'], null, 2));
console.log("OAUTH_OPENID:", JSON.stringify(parsed.paths['/api/oauth/openid'], null, 2));
}

13
get_auth_paths.cjs Normal file
View File

@@ -0,0 +1,13 @@
const fs = require('fs');
const content = fs.readFileSync('romm_swagger.json');
try {
let jsonString = content.toString('utf16le');
if (jsonString.charCodeAt(0) === 0xFEFF) { jsonString = jsonString.slice(1); }
const parsed = JSON.parse(jsonString);
const paths = Object.keys(parsed.paths).filter(p => p.includes('auth') || p.includes('sso') || p.includes('oauth') || p.includes('login'));
console.log(paths.join('\n'));
} catch (e) {
const parsed = JSON.parse(content.toString('utf8'));
const paths = Object.keys(parsed.paths).filter(p => p.includes('auth') || p.includes('sso') || p.includes('oauth') || p.includes('login'));
console.log(paths.join('\n'));
}

16
get_paths.cjs Normal file
View File

@@ -0,0 +1,16 @@
const fs = require('fs');
// read file using exact encoding
const content = fs.readFileSync('romm_swagger.json');
try {
let jsonString = content.toString('utf16le');
if (jsonString.charCodeAt(0) === 0xFEFF) {
jsonString = jsonString.slice(1);
}
const parsed = JSON.parse(jsonString);
const paths = Object.keys(parsed.paths).filter(p => p.includes('user') || p.includes('me'));
console.log(paths.join('\n'));
} catch (e) {
const parsed = JSON.parse(content.toString('utf8'));
const paths = Object.keys(parsed.paths).filter(p => p.includes('user') || p.includes('me'));
console.log(paths.join('\n'));
}

18
get_paths.js Normal file
View File

@@ -0,0 +1,18 @@
const fs = require('fs');
// read file using exact encoding
const content = fs.readFileSync('romm_swagger.json');
try {
// Try reading it as utf16le just in case PowerShell dumped it with BOM
let jsonString = content.toString('utf16le');
if (jsonString.charCodeAt(0) === 0xFEFF) {
jsonString = jsonString.slice(1);
}
const parsed = JSON.parse(jsonString);
const paths = Object.keys(parsed.paths).filter(p => p.includes('user') || p.includes('me'));
console.log(paths.join('\n'));
} catch (e) {
// Fallback to utf8 just in case
const parsed = JSON.parse(content.toString('utf8'));
const paths = Object.keys(parsed.paths).filter(p => p.includes('user') || p.includes('me'));
console.log(paths.join('\n'));
}

13
get_schema.cjs Normal file
View File

@@ -0,0 +1,13 @@
const fs = require('fs');
const content = fs.readFileSync('romm_swagger.json');
try {
let jsonString = content.toString('utf16le');
if (jsonString.charCodeAt(0) === 0xFEFF) { jsonString = jsonString.slice(1); }
const parsed = JSON.parse(jsonString);
const paths = Object.keys(parsed.paths).filter(p => p.includes('avatar') || p.includes('asset'));
console.log(paths.join('\n'));
} catch (e) {
const parsed = JSON.parse(content.toString('utf8'));
const paths = Object.keys(parsed.paths).filter(p => p.includes('avatar') || p.includes('asset'));
console.log(paths.join('\n'));
}

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RomM Web UI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
</head>
<body class="bg-background text-foreground">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7494
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "romm-web-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^2.8.10",
"@heroui/theme": "^2.4.26",
"@noriginmedia/norigin-spatial-navigation": "^3.0.0",
"@tanstack/query-sync-storage-persister": "^5.94.5",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-persist-client": "^5.94.5",
"clsx": "^2.1.1",
"framer-motion": "^11.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.15",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
romm_swagger.json Normal file

Binary file not shown.

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
}

448
stitch.html Normal file
View File

@@ -0,0 +1,448 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>The Digital Atelier - Home</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.geist-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.thumbnail-active {
opacity: 1 !important;
border: 2px solid #2563eb !important;
border-radius: 6px !important;
}
</style>
</head>
<body class="bg-[#10131f] text-[#c3c6d7] font-geist selection:bg-[#2563eb]/30 overflow-hidden">
<!-- SideNavBar Anchor -->
<aside class="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 class="px-6 mb-10">
<h1 class="text-xl font-black text-white tracking-tighter uppercase geist-mono">The Digital Atelier</h1>
<p class="text-[10px] geist-mono uppercase tracking-[0.05em] text-[#c3c6d7] opacity-60">Curated Vault</p>
</div>
<nav class="flex-1 flex flex-col gap-1">
<a class="bg-[#2563eb]/10 text-[#2563eb] backdrop-blur-md rounded-full mx-2 py-3 px-4 flex items-center gap-3 active:scale-95 transition-all" href="#">
<span class="material-symbols-outlined" data-icon="home">home</span>
<span class="geist-mono tracking-tight text-sm uppercase">Home</span>
</a>
<a class="text-[#c3c6d7] hover:text-white hover:bg-white/5 mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95" href="#">
<span class="material-symbols-outlined" data-icon="bookmarks">bookmarks</span>
<span class="geist-mono tracking-tight text-sm uppercase">Platforms</span>
</a>
<a class="text-[#c3c6d7] hover:text-white hover:bg-white/5 mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95" href="#">
<span class="material-symbols-outlined" data-icon="library_books">library_books</span>
<span class="geist-mono tracking-tight text-sm uppercase">Collections</span>
</a>
<a class="text-[#c3c6d7] hover:text-white hover:bg-white/5 mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95" href="#">
<span class="material-symbols-outlined" data-icon="videogame_asset">videogame_asset</span>
<span class="geist-mono tracking-tight text-sm uppercase">Console</span>
</a>
</nav>
<div class="mt-auto flex flex-col gap-1 pt-6">
<a class="text-[#c3c6d7] hover:text-white hover:bg-white/5 mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95" href="#">
<span class="material-symbols-outlined" data-icon="settings">settings</span>
<span class="geist-mono tracking-tight text-sm uppercase">Settings</span>
</a>
<a class="text-[#c3c6d7] hover:text-white hover:bg-white/5 mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95" href="#">
<span class="material-symbols-outlined" data-icon="help_outline">help_outline</span>
<span class="geist-mono tracking-tight text-sm uppercase">Support</span>
</a>
<div class="px-6 mt-4 flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-white/10 overflow-hidden">
<img alt="User profile avatar" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD6-iqa8Rg2GOiRcezo2bHGcW-qSPDPSuPHswQITO1jjFzfy8Tz4emYZ01W2LDBP96TpLRx8Q_3RzztdNWc9lQYuWLgEQ9VlatIK_FiOAmJlhsVTC4eAVYo2H25CwF8aIOwq2lqI5wnmGVo_rmlgp9vIAHvdCg9eO77fG5uhaSJZZ156M7RYEhg4PTNPhKIVp3XkX4u-tD56XaMiytgjMTK7VJfl4642t-JYltjZ2r6XhgnyOCG1BqOh1F_Wo5-e96NHMTu4X1iLRw"/>
</div>
<div class="flex flex-col leading-none">
<span class="text-xs font-bold text-white geist-mono">Archivist</span>
<span class="text-[10px] text-[#c3c6d7] font-medium uppercase tracking-tighter geist-mono">Admin</span>
</div>
</div>
</div>
</aside>
<!-- TopNavBar -->
<header class="fixed top-0 right-0 left-64 h-16 z-50 bg-[#10131f]/80 backdrop-blur-2xl flex items-center justify-between px-12 w-auto border-b border-white/5">
<div class="flex items-center gap-6 w-full">
<div class="relative w-full max-w-md focus-within:ring-1 focus-within:ring-[#2563eb]/50 rounded-md overflow-hidden bg-white/5">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-[#c3c6d7] text-sm">search</span>
<input class="w-full bg-transparent border-none pl-10 pr-4 py-2 text-sm text-white placeholder-[#c3c6d7]/50 focus:ring-0 geist-mono" placeholder="Search the vault..." type="text"/>
</div>
<!-- One-liner stats -->
<div class="hidden xl:flex items-center gap-6 ml-4 geist-mono flex-1 justify-center">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[#2563eb] text-sm" data-icon="account_tree">account_tree</span>
<span class="text-[11px] uppercase text-[#c3c6d7] font-bold"><span class="text-white">12</span> Platforms</span>
</div>
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[#2563eb] text-sm" data-icon="sports_esports">sports_esports</span>
<span class="text-[11px] uppercase text-[#c3c6d7] font-bold"><span class="text-white">1,402</span> Games</span>
</div>
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[#2563eb] text-sm" data-icon="hard_drive">hard_drive</span>
<span class="text-[11px] uppercase text-[#c3c6d7] font-bold"><span class="text-white">2.4 TB</span></span>
</div>
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[#2563eb] text-sm" data-icon="save">save</span>
<span class="text-[11px] uppercase text-[#c3c6d7] font-bold"><span class="text-white">842</span> Saves</span>
</div>
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[#2563eb] text-sm" data-icon="history">history</span>
<span class="text-[11px] uppercase text-[#c3c6d7] font-bold"><span class="text-white">142</span> States</span>
</div>
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[#2563eb] text-sm" data-icon="photo_library">photo_library</span>
<span class="text-[11px] uppercase text-[#c3c6d7] font-bold"><span class="text-white">5,103</span> Screens</span>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="ml-64 pt-16 h-screen overflow-y-auto no-scrollbar">
<!-- Featured Galleria Section -->
<section class="bg-[#10131f] border-b border-white/5" id="featured-section">
<div class="px-12 py-12">
<div class="flex gap-12 items-stretch max-w-full">
<!-- Large Hero Poster on the left -->
<div class="w-[400px] aspect-[3/4] shrink-0 bg-white/5 overflow-hidden rounded-md shadow-2xl">
<img alt="Hero Poster" class="w-full h-full object-cover" id="hero-poster" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBUhUkSXQHbjcUk0giuzo4xLtdCITpVPtDFEdrvea97_1irhgudxYgcyJQ3LOdTW1noAKV8wsN-ezHOrz9rerGXMsc8d-6lNiwf7ls7rihej6nuVloixoBODwfXdC9YnTeFuhoHmXnPySvjIZl9fR7B2dmUqWQS9HGWOiCzynYCDsh5MwikbBsg8GUSpcQLaEGigeJmABNTqjGEUTLSUzaJ_VArKU1pYQkTpc6gXSiMkC_AVb5ccr6YJp40LS7svN0PQ9G5vpkSHJw"/>
</div>
<!-- Content Area on the right -->
<div class="flex-1 flex flex-col justify-between">
<!-- Top portion -->
<div class="flex flex-col">
<div class="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 class="material-symbols-outlined text-[10px]" style="font-variation-settings: 'FILL' 1;">star</span>
<span class="text-[10px] font-black uppercase tracking-widest geist-mono" id="hero-tag">Action Gold</span>
</div>
<h2 class="text-[72px] font-black text-white tracking-tighter mb-4 leading-[0.9] uppercase geist-mono" id="hero-title">SUNSET OVERDRIVE</h2>
<!-- Metadata Grid -->
<div class="flex gap-10 mb-8 geist-mono">
<div class="flex flex-col">
<span class="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Platform</span>
<span class="text-xs font-black text-white" id="hero-platform">XBOX ONE / PC</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Developer</span>
<span class="text-xs font-black text-white" id="hero-dev">Fizz Co</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Year</span>
<span class="text-xs font-black text-white" id="hero-year">2014</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Rating</span>
<span class="text-xs font-black text-white flex items-center gap-1" id="hero-rating">
<span class="material-symbols-outlined text-[10px] text-yellow-500" style="font-variation-settings: 'FILL' 1;">star</span> 8.8
</span>
</div>
</div>
<p class="text-sm text-white/90 leading-relaxed max-w-2xl mb-8 geist-mono" id="hero-desc">
Style over everything. Grind, jump, and wall-run through a colorful post-apocalyptic city in the most energetic shooter ever made.
</p>
<!-- Action Row -->
<div class="flex items-center gap-3 mb-8">
<button class="bg-[#2563eb] hover:bg-[#2563eb]/90 text-white h-11 px-8 font-black flex items-center gap-2 active:scale-95 transition-all text-[11px] uppercase geist-mono rounded-md">
<span class="material-symbols-outlined text-base" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Play Now
</button>
<button class="bg-white/5 hover:bg-white/10 border border-white/10 text-white h-11 px-8 font-black flex items-center gap-2 transition-all text-[11px] uppercase geist-mono rounded-md">
<span class="material-symbols-outlined text-base">download</span>
Download
</button>
<button class="bg-white/5 hover:bg-white/10 border border-white/10 text-white h-11 w-11 flex items-center justify-center transition-all rounded-md">
<span class="material-symbols-outlined text-base">library_add</span>
</button>
</div>
</div>
<!-- Bottom portion: Thumbnail row -->
<div class="relative w-full overflow-hidden">
<div class="flex gap-3 overflow-x-auto no-scrollbar scroll-smooth" id="carousel-container">
<!-- Items generated via script -->
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Other Sections -->
<div class="px-12 py-16 space-y-20 pb-24">
<!-- Recently Added -->
<section>
<div class="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
<h3 class="text-xl font-black text-white uppercase tracking-tighter geist-mono">Recently Added</h3>
<a class="text-[10px] font-black text-[#2563eb] hover:underline uppercase geist-mono tracking-[0.3em]" href="#">View All</a>
</div>
<div class="flex gap-6 overflow-x-auto no-scrollbar pb-4">
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBE_Bwp3p2xwGRJbX0mMY130beG6FoRAeWcKQjFJQ1TemSkfm9oUpgoWokReYnkA8xlGeEKLPfSe6IS3KAMwk7uOIZ-W7lNFxuVRguT5HQsKMeIFNBwNAdzX6Dv-zB0UcR9MrZGeh-P937ZVdZlacyWKhd9UlrYYXKWs0WrlfzCwyih9iuOdBghRwGUPCSMZYl4GbaktvJvYaR4umqCssOOgxo4MJK2qMu_9MNT6TBPyxQAGAnMih8bz3c37weOAsALioyqUH_bjiY"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Lunar Colony</h4>
<p class="text-[10px] text-[#c3c6d7] uppercase geist-mono tracking-widest">Strategy</p>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCdZ6xp9ZMjTyJAWKmRuJFddxg7y2QXMYoQ9LeHyylQzDzJrnhJ9AtMH09t2IagtwxKIlyOIEHjYKsXZ-oxOr0HIGPSQhfbqi18wFk_4GBQWixpT6DxIA49JS-Nc2Aaka7-duczcRYIisv650f40n_HxzapPIgrdgWYKY03ykvHBYF4LIvfA6IyJgSyesR90E8vb-LUcNjcoExS5QD3IHV-pmbMeIikXzUN9FsUnkNUK66mCjRb7YuZ69g1UpU4JDfpv6r-drxrJ-0"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">The Golem Project</h4>
<p class="text-[10px] text-[#c3c6d7] uppercase geist-mono tracking-widest">Action</p>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBX4MjWOaUI2OT7G-1EH4iX8fwmchgRIyNRL7agFG5GxTi-RWqKUM2cD8qL_8kZS17opkEzmt0Z5pb7U90EzpaHtbYZxHsxP05NPSXm1qsDamkV4vtb-wovTv4W5CuQqINNI2TtUAbSKcjnlANabj_NfmrfjRjcjEjxsBVzt_25lrsJKa-PEgVhvF_stJeAlL-DVVw0uOO87ZyQ0ZLlQcVRx4St4BfA8WIRyY6UnL5Jtj03fa8hO-mqEsyARLPQ0wGBX_nZOfPao4Y"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Vector Prime</h4>
<p class="text-[10px] text-[#c3c6d7] uppercase geist-mono tracking-widest">Arcade</p>
</div>
</div>
</section>
<!-- Continue Playing -->
<section>
<div class="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
<h3 class="text-xl font-black text-white uppercase tracking-tighter geist-mono">Continue Playing</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="bg-white/5 p-6 flex gap-6 hover:bg-white/10 transition-all cursor-pointer group rounded-md ring-1 ring-white/10">
<div class="w-28 aspect-square bg-white/10 overflow-hidden shrink-0 rounded-md">
<img class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCg7VYbc9iGTxKmei73JUyk9phRdk_vhe8hLzpky0tU8YefgFG_WwZE21o-gGr2IWPZ58fE5a4sYVL2mdzcmyqQdDemL_erw6Ql0KB4jthOnvSAUCO5VTfmB11yD-sbjSFHOKKEqI4eiBileSBNLjdEewVvpvUNpx2zUvFhRtJl2Y5T7q06eMDtNh3phArXk8CFWDPymoa5H5YwKyhtwknjFQsxNIxQfdLALzyDCuAn4I5HeafBZiMhcuOVYM8egHBbW2raYd7xyqU"/>
</div>
<div class="flex-1 flex flex-col justify-center">
<h4 class="text-sm font-black text-white mb-3 geist-mono uppercase">Cyberpunk Reclamation</h4>
<div class="flex items-center justify-between text-[10px] text-[#c3c6d7] uppercase geist-mono mb-2 font-bold">
<span>84% Complete</span>
<span class="text-[#2563eb]">Active</span>
</div>
<div class="h-1 bg-white/5 overflow-hidden rounded-full">
<div class="h-full bg-[#2563eb] w-[84%]"></div>
</div>
</div>
</div>
</div>
</section>
<!-- Favorites -->
<section>
<div class="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
<h3 class="text-xl font-black text-white uppercase tracking-tighter geist-mono">Favorites</h3>
<a class="text-[10px] font-black text-[#2563eb] hover:underline uppercase geist-mono tracking-[0.3em]" href="#">View All</a>
</div>
<div class="flex gap-6 overflow-x-auto no-scrollbar pb-4">
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCg7VYbc9iGTxKmei73JUyk9phRdk_vhe8hLzpky0tU8YefgFG_WwZE21o-gGr2IWPZ58fE5a4sYVL2mdzcmyqQdDemL_erw6Ql0KB4jthOnvSAUCO5VTfmB11yD-sbjSFHOKKEqI4eiBileSBNLjdEewVvpvUNpx2zUvFhRtJl2Y5T7q06eMDtNh3phArXk8CFWDPymoa5H5YwKyhtwknjFQsxNIxQfdLALzyDCuAn4I5HeafBZiMhcuOVYM8egHBbW2raYd7xyqU"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Cyberpunk Reclamation</h4>
<p class="text-[10px] text-[#c3c6d7] uppercase geist-mono tracking-widest">RPG</p>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCdZ6xp9ZMjTyJAWKmRuJFddxg7y2QXMYoQ9LeHyylQzDzJrnhJ9AtMH09t2IagtwxKIlyOIEHjYKsXZ-oxOr0HIGPSQhfbqi18wFk_4GBQWixpT6DxIA49JS-Nc2Aaka7-duczcRYIisv650f40n_HxzapPIgrdgWYKY03ykvHBYF4LIvfA6IyJgSyesR90E8vb-LUcNjcoExS5QD3IHV-pmbMeIikXzUN9FsUnkNUK66mCjRb7YuZ69g1UpU4JDfpv6r-drxrJ-0"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">The Golem Project</h4>
<p class="text-[10px] text-[#c3c6d7] uppercase geist-mono tracking-widest">Action</p>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBUhUkSXQHbjcUk0giuzo4xLtdCITpVPtDFEdrvea97_1irhgudxYgcyJQ3LOdTW1noAKV8wsN-ezHOrz9rerGXMsc8d-6lNiwf7ls7rihej6nuVloixoBODwfXdC9YnTeFuhoHmXnPySvjIZl9fR7B2dmUqWQS9HGWOiCzynYCDsh5MwikbBsg8GUSpcQLaEGigeJmABNTqjGEUTLSUzaJ_VArKU1pYQkTpc6gXSiMkC_AVb5ccr6YJp40LS7svN0PQ9G5vpkSHJw"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Sunset Overdrive</h4>
<p class="text-[10px] text-[#c3c6d7] uppercase geist-mono tracking-widest">Action</p>
</div>
</div>
</section>
<!-- Collections -->
<section>
<div class="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
<h3 class="text-xl font-black text-white uppercase tracking-tighter geist-mono">Collections</h3>
<a class="text-[10px] font-black text-[#2563eb] hover:underline uppercase geist-mono tracking-[0.3em]" href="#">View All</a>
</div>
<div class="flex gap-6 overflow-x-auto no-scrollbar pb-4">
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBUhUkSXQHbjcUk0giuzo4xLtdCITpVPtDFEdrvea97_1irhgudxYgcyJQ3LOdTW1noAKV8wsN-ezHOrz9rerGXMsc8d-6lNiwf7ls7rihej6nuVloixoBODwfXdC9YnTeFuhoHmXnPySvjIZl9fR7B2dmUqWQS9HGWOiCzynYCDsh5MwikbBsg8GUSpcQLaEGigeJmABNTqjGEUTLSUzaJ_VArKU1pYQkTpc6gXSiMkC_AVb5ccr6YJp40LS7svN0PQ9G5vpkSHJw"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Neon Noir</h4>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCg7VYbc9iGTxKmei73JUyk9phRdk_vhe8hLzpky0tU8YefgFG_WwZE21o-gGr2IWPZ58fE5a4sYVL2mdzcmyqQdDemL_erw6Ql0KB4jthOnvSAUCO5VTfmB11yD-sbjSFHOKKEqI4eiBileSBNLjdEewVvpvUNpx2zUvFhRtJl2Y5T7q06eMDtNh3phArXk8CFWDPymoa5H5YwKyhtwknjFQsxNIxQfdLALzyDCuAn4I5HeafBZiMhcuOVYM8egHBbW2raYd7xyqU"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Cyberpunk Classics</h4>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBE_Bwp3p2xwGRJbX0mMY130beG6FoRAeWcKQjFJQ1TemSkfm9oUpgoWokReYnkA8xlGeEKLPfSe6IS3KAMwk7uOIZ-W7lNFxuVRguT5HQsKMeIFNBwNAdzX6Dv-zB0UcR9MrZGeh-P937ZVdZlacyWKhd9UlrYYXKWs0WrlfzCwyih9iuOdBghRwGUPCSMZYl4GbaktvJvYaR4umqCssOOgxo4MJK2qMu_9MNT6TBPyxQAGAnMih8bz3c37weOAsALioyqUH_bjiY"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Atmospheric Sims</h4>
</div>
</div>
</section>
<!-- Autogenerated Collections -->
<section>
<div class="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
<h3 class="text-xl font-black text-white uppercase tracking-tighter geist-mono">Autogenerated Collections</h3>
</div>
<div class="flex gap-6 overflow-x-auto no-scrollbar pb-4">
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBX4MjWOaUI2OT7G-1EH4iX8fwmchgRIyNRL7agFG5GxTi-RWqKUM2cD8qL_8kZS17opkEzmt0Z5pb7U90EzpaHtbYZxHsxP05NPSXm1qsDamkV4vtb-wovTv4W5CuQqINNI2TtUAbSKcjnlANabj_NfmrfjRjcjEjxsBVzt_25lrsJKa-PEgVhvF_stJeAlL-DVVw0uOO87ZyQ0ZLlQcVRx4St4BfA8WIRyY6UnL5Jtj03fa8hO-mqEsyARLPQ0wGBX_nZOfPao4Y"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Arcade Revival</h4>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBE_Bwp3p2xwGRJbX0mMY130beG6FoRAeWcKQjFJQ1TemSkfm9oUpgoWokReYnkA8xlGeEKLPfSe6IS3KAMwk7uOIZ-W7lNFxuVRguT5HQsKMeIFNBwNAdzX6Dv-zB0UcR9MrZGeh-P937ZVdZlacyWKhd9UlrYYXKWs0WrlfzCwyih9iuOdBghRwGUPCSMZYl4GbaktvJvYaR4umqCssOOgxo4MJK2qMu_9MNT6TBPyxQAGAnMih8bz3c37weOAsALioyqUH_bjiY"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Deep Space Strategy</h4>
</div>
<div class="w-56 shrink-0 cursor-pointer group">
<div class="aspect-[2/3] bg-white/5 overflow-hidden mb-4 ring-1 ring-white/10 rounded-[6px]">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCdZ6xp9ZMjTyJAWKmRuJFddxg7y2QXMYoQ9LeHyylQzDzJrnhJ9AtMH09t2IagtwxKIlyOIEHjYKsXZ-oxOr0HIGPSQhfbqi18wFk_4GBQWixpT6DxIA49JS-Nc2Aaka7-duczcRYIisv650f40n_HxzapPIgrdgWYKY03ykvHBYF4LIvfA6IyJgSyesR90E8vb-LUcNjcoExS5QD3IHV-pmbMeIikXzUN9FsUnkNUK66mCjRb7YuZ69g1UpU4JDfpv6r-drxrJ-0"/>
</div>
<h4 class="text-sm font-black text-white truncate geist-mono uppercase">Kinetic Combat</h4>
</div>
</div>
</section>
</div>
</main>
<script>
const gamesData = [
{
title: "SUNSET OVERDRIVE",
platform: "XBOX ONE / PC",
dev: "Fizz Co",
year: "2014",
rating: "8.8",
desc: "Style over everything. Grind, jump, and wall-run through a colorful post-apocalyptic city in the most energetic shooter ever made.",
tag: "Action Gold",
img: "https://lh3.googleusercontent.com/aida-public/AB6AXuBUhUkSXQHbjcUk0giuzo4xLtdCITpVPtDFEdrvea97_1irhgudxYgcyJQ3LOdTW1noAKV8wsN-ezHOrz9rerGXMsc8d-6lNiwf7ls7rihej6nuVloixoBODwfXdC9YnTeFuhoHmXnPySvjIZl9fR7B2dmUqWQS9HGWOiCzynYCDsh5MwikbBsg8GUSpcQLaEGigeJmABNTqjGEUTLSUzaJ_VArKU1pYQkTpc6gXSiMkC_AVb5ccr6YJp40LS7svN0PQ9G5vpkSHJw"
},
{
title: "Cyberpunk Reclamation",
platform: "PC / ARCHIVE",
dev: "Project Orion",
year: "2077",
rating: "9.8",
desc: "Experience the definitive edition of the neon-noir classic. Fully restored assets and curated archival content from the deepest vaults of the Digital Atelier.",
tag: "Masterpiece Collection",
img: "https://lh3.googleusercontent.com/aida-public/AB6AXuCg7VYbc9iGTxKmei73JUyk9phRdk_vhe8hLzpky0tU8YefgFG_WwZE21o-gGr2IWPZ58fE5a4sYVL2mdzcmyqQdDemL_erw6Ql0KB4jthOnvSAUCO5VTfmB11yD-sbjSFHOKKEqI4eiBileSBNLjdEewVvpvUNpx2zUvFhRtJl2Y5T7q06eMDtNh3phArXk8CFWDPymoa5H5YwKyhtwknjFQsxNIxQfdLALzyDCuAn4I5HeafBZiMhcuOVYM8egHBbW2raYd7xyqU"
},
{
title: "Lunar Colony",
platform: "PC / VR",
dev: "Stellar Ops",
year: "2042",
rating: "8.5",
desc: "Command the first permanent lunar settlement in this intricate survival strategy simulation. Manage oxygen, power, and human fragile spirits.",
tag: "Editor's Choice",
img: "https://lh3.googleusercontent.com/aida-public/AB6AXuBE_Bwp3p2xwGRJbX0mMY130beG6FoRAeWcKQjFJQ1TemSkfm9oUpgoWokReYnkA8xlGeEKLPfSe6IS3KAMwk7uOIZ-W7lNFxuVRguT5HQsKMeIFNBwNAdzX6Dv-zB0UcR9MrZGeh-P937ZVdZlacyWKhd9UlrYYXKWs0WrlfzCwyih9iuOdBghRwGUPCSMZYl4GbaktvJvYaR4umqCssOOgxo4MJK2qMu_9MNT6TBPyxQAGAnMih8bz3c37weOAsALioyqUH_bjiY"
},
{
title: "The Golem Project",
platform: "CONSOLE / NEXT-GEN",
dev: "Anima Works",
year: "2024",
rating: "9.2",
desc: "A fast-paced kinetic action RPG where you craft your own mechanical guardian from the scrap of a fallen civilization.",
tag: "Must Play",
img: "https://lh3.googleusercontent.com/aida-public/AB6AXuCdZ6xp9ZMjTyJAWKmRuJFddxg7y2QXMYoQ9LeHyylQzDzJrnhJ9AtMH09t2IagtwxKIlyOIEHjYKsXZ-oxOr0HIGPSQhfbqi18wFk_4GBQWixpT6DxIA49JS-Nc2Aaka7-duczcRYIisv650f40n_HxzapPIgrdgWYKY03ykvHBYF4LIvfA6IyJgSyesR90E8vb-LUcNjcoExS5QD3IHV-pmbMeIikXzUN9FsUnkNUK66mCjRb7YuZ69g1UpU4JDfpv6r-drxrJ-0"
},
{
title: "Vector Prime",
platform: "RETRO ARCADE",
dev: "Neo Geo",
year: "1998",
rating: "7.9",
desc: "The definitive retro arcade shooter experience. Battle through 32 levels of vector-based chaos with a pulsing synthwave soundtrack.",
tag: "Retro Vault",
img: "https://lh3.googleusercontent.com/aida-public/AB6AXuBX4MjWOaUI2OT7G-1EH4iX8fwmchgRIyNRL7agFG5GxTi-RWqKUM2cD8qL_8kZS17opkEzmt0Z5pb7U90EzpaHtbYZxHsxP05NPSXm1qsDamkV4vtb-wovTv4W5CuQqINNI2TtUAbSKcjnlANabj_NfmrfjRjcjEjxsBVzt_25lrsJKa-PEgVhvF_stJeAlL-DVVw0uOO87ZyQ0ZLlQcVRx4St4BfA8WIRyY6UnL5Jtj03fa8hO-mqEsyARLPQ0wGBX_nZOfPao4Y"
}
];
const carouselItems = [];
for(let i=0; i<25; i++) {
carouselItems.push(gamesData[i % gamesData.length]);
}
const container = document.getElementById('carousel-container');
const heroPoster = document.getElementById('hero-poster');
const heroTitle = document.getElementById('hero-title');
const heroPlatform = document.getElementById('hero-platform');
const heroDev = document.getElementById('hero-dev');
const heroYear = document.getElementById('hero-year');
const heroRating = document.getElementById('hero-rating');
const heroDesc = document.getElementById('hero-desc');
const heroTag = document.getElementById('hero-tag');
let currentIndex = 0;
let autoScrollInterval;
let isPaused = false;
function updateFeatured(index) {
currentIndex = index;
const game = carouselItems[index];
heroPoster.src = game.img;
heroTitle.textContent = game.title.toUpperCase();
heroPlatform.textContent = game.platform;
heroDev.textContent = game.dev;
heroYear.textContent = game.year;
heroRating.innerHTML = `<span class="material-symbols-outlined text-[10px] text-yellow-500" style="font-variation-settings: 'FILL' 1;">star</span> ${game.rating}`;
heroDesc.textContent = game.desc;
heroTag.textContent = game.tag.toUpperCase();
document.querySelectorAll('.carousel-thumb').forEach((thumb, i) => {
if(i === index) {
thumb.classList.add('thumbnail-active');
} else {
thumb.classList.remove('thumbnail-active');
}
});
const targetThumb = container.children[index];
if (targetThumb) {
container.scrollTo({
left: targetThumb.offsetLeft - (container.clientWidth / 2) + (targetThumb.clientWidth / 2),
behavior: 'smooth'
});
}
}
carouselItems.forEach((game, i) => {
const thumb = document.createElement('div');
thumb.className = `carousel-thumb w-[90px] shrink-0 aspect-[2/3] overflow-hidden cursor-pointer transition-all duration-300 ring-1 ring-white/10 opacity-60 hover:opacity-100 bg-white/5 rounded-[6px]`;
thumb.innerHTML = `<img class="w-full h-full object-cover" src="${game.img}"/>`;
thumb.onclick = () => {
updateFeatured(i);
stopAutoScroll();
startAutoScroll();
};
container.appendChild(thumb);
});
function startAutoScroll() {
autoScrollInterval = setInterval(() => {
if (!isPaused) {
currentIndex = (currentIndex + 1) % carouselItems.length;
updateFeatured(currentIndex);
}
}, 5000);
}
function stopAutoScroll() {
clearInterval(autoScrollInterval);
}
const featuredSection = document.getElementById('featured-section');
featuredSection.onmouseenter = () => isPaused = true;
featuredSection.onmouseleave = () => isPaused = false;
updateFeatured(0);
startAutoScroll();
</script>
</body></html>

310
stitch_settings.html Normal file
View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary-container": "#2563eb",
"surface-container": "#1c1f2c",
"surface-container-lowest": "#0b0d1a",
"secondary-fixed-dim": "#d2bbff",
"primary-fixed": "#dbe1ff",
"primary": "#b4c5ff",
"on-error-container": "#ffdad6",
"outline-variant": "#434655",
"surface-container-high": "#272937",
"on-tertiary-fixed": "#181b24",
"on-primary": "#002a78",
"surface-container-low": "#181b28",
"primary-fixed-dim": "#b4c5ff",
"on-secondary-fixed-variant": "#5a00c6",
"on-secondary-fixed": "#25005a",
"on-primary-fixed-variant": "#003ea8",
"surface-bright": "#363847",
"inverse-primary": "#0053db",
"outline": "#8d90a0",
"on-secondary-container": "#c9aeff",
"surface": "#10131f",
"surface-tint": "#b4c5ff",
"surface-dim": "#10131f",
"on-error": "#690005",
"error-container": "#93000a",
"secondary-fixed": "#eaddff",
"surface-variant": "#323442",
"on-surface": "#e0e1f4",
"on-background": "#e0e1f4",
"secondary": "#d2bbff",
"inverse-surface": "#e0e1f4",
"surface-container-highest": "#323442",
"on-secondary": "#3f008e",
"on-primary-container": "#eeefff",
"tertiary-fixed-dim": "#c4c6d2",
"tertiary-fixed": "#e0e2ee",
"secondary-container": "#6001d1",
"inverse-on-surface": "#2d303e",
"tertiary": "#c4c6d2",
"on-tertiary-fixed-variant": "#444650",
"on-tertiary": "#2d3039",
"on-primary-fixed": "#00174b",
"tertiary-container": "#6a6d78",
"on-tertiary-container": "#eef0fc",
"error": "#ffb4ab",
"on-surface-variant": "#c3c6d7",
"background": "#10131f"
},
fontFamily: {
"headline": ["Inter"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "1rem", "lg": "2rem", "xl": "3rem", "full": "9999px"},
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body {
background-color: #10131f;
color: #e0e1f4;
font-family: 'Inter', sans-serif;
}
.hide-scrollbar::-webkit-scrollbar { display: none; }
.glass-panel {
background: rgba(28, 31, 44, 0.7);
backdrop-filter: blur(20px);
}
</style>
</head>
<body class="antialiased">
<!-- TopNavBar -->
<nav class="fixed top-0 w-full z-50 h-16 bg-[#10131f] bg-opacity-90 backdrop-blur-xl flex items-center justify-between px-6 shadow-2xl shadow-black/50 font-['Inter'] antialiased tracking-tight">
<div class="flex items-center gap-8 w-full max-w-7xl mx-auto">
<!-- Brand -->
<div class="text-xl font-bold tracking-tighter text-slate-100 shrink-0">
The Digital Atelier
</div>
<!-- Search Bar (on_left) -->
<div class="relative max-w-md w-full ml-4">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">search</span>
<input class="w-full bg-white/5 border-none rounded-full py-2 pl-10 pr-4 text-sm text-slate-200 focus:ring-2 focus:ring-blue-600 transition-all placeholder:text-slate-500" placeholder="Search the vault..." type="text"/>
</div>
<!-- Centered Stats -->
<div class="hidden lg:flex items-center gap-6 mx-auto">
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase tracking-widest text-slate-500 font-bold">Games</span>
<span class="text-blue-500 font-bold">12,482</span>
</div>
<div class="w-px h-6 bg-white/10"></div>
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase tracking-widest text-slate-500 font-bold">Storage</span>
<span class="text-blue-500 font-bold">4.2 TB</span>
</div>
</div>
<!-- Trailing Icons -->
<div class="flex items-center gap-4 shrink-0">
<button class="p-2 text-slate-400 hover:text-slate-100 hover:bg-white/5 rounded-full transition-all duration-200 active:scale-95 cursor-pointer">
<span class="material-symbols-outlined">notifications</span>
</button>
<button class="p-2 text-blue-500 font-semibold hover:bg-white/5 rounded-full transition-all duration-200 active:scale-95 cursor-pointer">
<span class="material-symbols-outlined">settings</span>
</button>
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-purple-600 p-[1px] cursor-pointer">
<img alt="User profile" class="w-full h-full rounded-full object-cover" data-alt="Close up of a professional male user avatar" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCfAtckX2He7yTMWIUG51cWyX7GKRwshh3bKOee22jc3QzbdJC3fx8P3sJ82Jb7qA6YmxFeMPKy2Ej82Lw1Y8HBRrEAJQl7TafEV-Avp0SblYlSfrIGczUawU4ArCXZ7X2dl4Auk0fHxsmVLNCj1BZKGO_JMIThDgLGF4EPpl64PdTNF0P5UAk-thHYvZ0xJ5sOK-sbfUO_HLhu100AKc4Py-DZAXMUSqrm9ozK2yO2xErKKqkOZC_m6_iyED4mk5rtRb4vaZ07TEg"/>
</div>
</div>
</div>
</nav>
<!-- SideNavBar -->
<aside class="fixed left-0 top-0 h-full w-64 z-[60] bg-[#181b28] shadow-[10px_0_30px_rgba(0,0,0,0.3)] flex flex-col p-4 gap-y-4 font-['Inter'] text-sm font-medium uppercase tracking-widest hidden md:flex">
<div class="mt-4 mb-8 px-4">
<div class="text-lg font-black bg-gradient-to-br from-blue-600 to-purple-600 bg-clip-text text-transparent">
The Vault
</div>
<div class="text-[10px] text-slate-500 font-bold -mt-1">Digital Preservation</div>
</div>
<nav class="flex-1 space-y-2">
<a class="flex items-center gap-3 text-slate-500 hover:text-slate-200 px-4 py-3 hover:bg-white/5 rounded-full transition-all hover:translate-x-1 duration-300" href="#">
<span class="material-symbols-outlined">home</span>
<span>Home</span>
</a>
<a class="flex items-center gap-3 text-slate-500 hover:text-slate-200 px-4 py-3 hover:bg-white/5 rounded-full transition-all hover:translate-x-1 duration-300" href="#">
<span class="material-symbols-outlined">grid_view</span>
<span>Platforms</span>
</a>
<a class="flex items-center gap-3 text-slate-500 hover:text-slate-200 px-4 py-3 hover:bg-white/5 rounded-full transition-all hover:translate-x-1 duration-300" href="#">
<span class="material-symbols-outlined">library_books</span>
<span>Collections</span>
</a>
<a class="flex items-center gap-3 text-slate-500 hover:text-slate-200 px-4 py-3 hover:bg-white/5 rounded-full transition-all hover:translate-x-1 duration-300" href="#">
<span class="material-symbols-outlined">videogame_asset</span>
<span>Console</span>
</a>
</nav>
<div class="pt-4 border-t border-white/5 space-y-2">
<a class="flex items-center gap-3 bg-white/10 backdrop-blur-md text-blue-400 rounded-full shadow-[0_0_15px_rgba(37,99,235,0.2)] px-4 py-3" href="#">
<span class="material-symbols-outlined">settings</span>
<span>Settings</span>
</a>
<a class="flex items-center gap-3 text-slate-500 hover:text-slate-200 px-4 py-3 hover:bg-white/5 rounded-full transition-all hover:translate-x-1 duration-300" href="#">
<span class="material-symbols-outlined">help</span>
<span>Support</span>
</a>
</div>
</aside>
<!-- Main Content Area -->
<main class="md:ml-64 pt-24 px-8 pb-12">
<div class="max-w-5xl mx-auto">
<!-- Page Header -->
<header class="mb-10">
<h1 class="text-4xl font-extrabold tracking-tight text-on-surface mb-2">Vault Console</h1>
<p class="text-on-surface-variant font-medium">Configure your library experience and server connections.</p>
</header>
<!-- Horizontal Tab Bar -->
<div class="flex items-center gap-2 mb-10 overflow-x-auto hide-scrollbar bg-surface-container-low p-1.5 rounded-full w-fit">
<button class="px-6 py-2.5 rounded-full text-sm font-bold text-on-surface-variant hover:text-on-surface transition-all">Profile</button>
<button class="px-6 py-2.5 rounded-full text-sm font-bold bg-primary-container text-on-primary-container shadow-lg shadow-primary-container/20 transition-all">User Interface</button>
<button class="px-6 py-2.5 rounded-full text-sm font-bold text-on-surface-variant hover:text-on-surface transition-all">Library Management</button>
<button class="px-6 py-2.5 rounded-full text-sm font-bold text-on-surface-variant hover:text-on-surface transition-all">Metadata Sources</button>
<button class="px-6 py-2.5 rounded-full text-sm font-bold text-on-surface-variant hover:text-on-surface transition-all">Administration</button>
<button class="px-6 py-2.5 rounded-full text-sm font-bold text-on-surface-variant hover:text-on-surface transition-all whitespace-nowrap">Server Stats</button>
</div>
<!-- Settings Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Column: Visual Identity -->
<section class="lg:col-span-7 space-y-6">
<div class="bg-surface-container-high rounded-lg p-8 shadow-xl">
<div class="flex items-center gap-3 mb-8">
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">palette</span>
<h2 class="text-xl font-bold tracking-tight text-on-surface">Visual Identity</h2>
</div>
<div class="space-y-8">
<!-- Toggle: Dark Mode -->
<div class="flex items-center justify-between group">
<div>
<p class="font-bold text-on-surface">Enable Dark Mode</p>
<p class="text-sm text-on-surface-variant">Switch between obsidian and slate themes.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox"/>
<div class="w-12 h-6 bg-surface-bright 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-primary-container"></div>
</label>
</div>
<!-- Toggle: Notifications -->
<div class="flex items-center justify-between group">
<div>
<p class="font-bold text-on-surface">Show Notifications</p>
<p class="text-sm text-on-surface-variant">Get alerts for scan completions and updates.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input class="sr-only peer" type="checkbox"/>
<div class="w-12 h-6 bg-surface-bright 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-primary-container"></div>
</label>
</div>
<!-- Toggle: Glassmorphism -->
<div class="flex items-center justify-between group">
<div>
<p class="font-bold text-on-surface">Glassmorphism Effects</p>
<p class="text-sm text-on-surface-variant">Enable translucent blurs and layered depth.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox"/>
<div class="w-12 h-6 bg-surface-bright 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-primary-container"></div>
</label>
</div>
</div>
</div>
<!-- Layout Preview Card (Editorial Touch) -->
<div class="bg-gradient-to-br from-primary-container/20 to-secondary-container/20 rounded-lg p-8 border border-white/5 relative overflow-hidden group">
<div class="relative z-10">
<h3 class="text-lg font-bold text-white mb-2">Interface Preview</h3>
<p class="text-sm text-blue-200/70 mb-6">Current: Ultra-Modern / Editorial</p>
<div class="flex gap-2">
<div class="w-12 h-2 bg-white/20 rounded-full"></div>
<div class="w-24 h-2 bg-white/40 rounded-full"></div>
<div class="w-8 h-2 bg-white/20 rounded-full"></div>
</div>
</div>
<span class="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>
<!-- Right Column: Connection & API -->
<section class="lg:col-span-5">
<div class="bg-surface-container-high rounded-lg p-8 shadow-xl h-full border border-white/5">
<div class="flex items-center gap-3 mb-8">
<span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">cloud_sync</span>
<h2 class="text-xl font-bold tracking-tight text-on-surface">Connection &amp; API</h2>
</div>
<div class="space-y-6">
<!-- Input: Server Address -->
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2 ml-1">Server Address</label>
<div class="relative">
<input class="w-full bg-surface-container-highest border-none rounded-md px-4 py-3 text-on-surface focus:ring-2 focus:ring-primary transition-all font-mono text-sm" type="text" value="romm.vault-local.io"/>
</div>
</div>
<!-- Input: API Port -->
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2 ml-1">API Port</label>
<div class="relative">
<input class="w-full bg-surface-container-highest border-none rounded-md px-4 py-3 text-on-surface focus:ring-2 focus:ring-primary transition-all font-mono text-sm" type="text" value="8080"/>
</div>
</div>
<!-- Input: IGDB Master API Key -->
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2 ml-1">IGDB Master API Key</label>
<div class="relative">
<input class="w-full bg-surface-container-highest border-none rounded-md px-4 py-3 text-on-surface focus:ring-2 focus:ring-primary transition-all font-mono text-sm" type="password" value="••••••••••••••••"/>
<button class="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant hover:text-on-surface">
<span class="material-symbols-outlined text-lg">visibility</span>
</button>
</div>
<p class="mt-2 text-[10px] text-on-surface-variant/60 italic leading-relaxed">Required for fetching high-resolution box art and historical metadata.</p>
</div>
<!-- Action Buttons -->
<div class="pt-6 flex flex-col gap-3">
<button class="w-full bg-gradient-to-br from-primary-container to-secondary-container text-white py-3 rounded-xl font-bold text-sm shadow-lg shadow-primary-container/30 hover:shadow-primary-container/50 transition-all active:scale-[0.98]">
Save Connections
</button>
<button class="w-full bg-white/5 text-on-surface-variant py-3 rounded-xl font-bold text-sm hover:bg-white/10 transition-all">
Test Latency
</button>
</div>
</div>
</div>
</section>
</div>
<!-- Footer Section: System Status -->
<footer class="mt-12 flex flex-col md:flex-row items-center justify-between gap-6 p-8 bg-surface-container-low rounded-lg border border-white/5">
<div class="flex items-center gap-4">
<div class="w-3 h-3 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]"></div>
<div>
<p class="text-sm font-bold text-on-surface">System Status: Optimal</p>
<p class="text-xs text-on-surface-variant">Last library scan: 42 minutes ago</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-1">Cores</p>
<p class="text-lg font-black text-on-surface">16</p>
</div>
<div class="text-center">
<p class="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-1">Latency</p>
<p class="text-lg font-black text-on-surface">12ms</p>
</div>
</div>
</footer>
</div>
</main>
</body></html>

27
tailwind.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Config } from "tailwindcss";
import { heroui } from "@heroui/theme";
const config: Config = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
animation: {
"background-position-spin": "background-position-spin 3000ms infinite alternate",
},
keyframes: {
"background-position-spin": {
"0%": { backgroundPosition: "top center" },
"100%": { backgroundPosition: "bottom center" },
},
},
},
},
darkMode: "class",
plugins: [heroui()],
};
export default config;

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
envPrefix: ['VITE_', 'ROMM_'],
server: {
watch: {
usePolling: true,
},
},
})