feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings
This commit is contained in:
19
.devcontainer/devcontainer.json
Normal file
19
.devcontainer/devcontainer.json
Normal 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
31
.gitignore
vendored
Normal 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
27
Dockerfile
Normal 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
12
docker-compose.dev.yml
Normal 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
21
docker-compose.yml
Normal 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
13
get_auth_details.cjs
Normal 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
13
get_auth_paths.cjs
Normal 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
16
get_paths.cjs
Normal 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
18
get_paths.js
Normal 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
13
get_schema.cjs
Normal 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
14
index.html
Normal 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
7494
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
romm_swagger.json
Normal file
BIN
romm_swagger.json
Normal file
Binary file not shown.
53
src/App.tsx
Normal file
53
src/App.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { TopNav } from './components/TopNav';
|
||||
import LibraryGrid from './components/LibraryGrid';
|
||||
import { Login } from './components/Login';
|
||||
import { Settings } from './components/Settings';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { useGamepad } from './hooks/useGamepad';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const MainLayout = () => {
|
||||
return (
|
||||
<div className="min-h-screen text-foreground selection:bg-[#2563eb]/30 overflow-hidden bg-[#10131f]">
|
||||
<Sidebar />
|
||||
<TopNav />
|
||||
<main className="ml-64 pt-16 h-screen overflow-y-auto no-scrollbar">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
useGamepad();
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<LibraryGrid />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
182
src/api/client.ts
Normal file
182
src/api/client.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
export interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
system: string;
|
||||
coverUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface RommCollection {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerRole: 'admin' | 'user';
|
||||
games: Game[];
|
||||
coverUrl?: string; // RomM collections can have intrinsic covers
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarUrl?: string;
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
// Function to safely extract base URL
|
||||
const getBaseUrl = () => {
|
||||
const rawUrl = import.meta.env.VITE_ROMM_BASE_URL || 'http://localhost:8080';
|
||||
const cleanUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
|
||||
return cleanUrl;
|
||||
};
|
||||
|
||||
// Function to handle external URLs directly mapped from SteamGrid or IGDB dynamically
|
||||
const getFullImageUrl = (urlPath: string | undefined): string | undefined => {
|
||||
if (!urlPath) return undefined;
|
||||
if (urlPath.startsWith('http://') || urlPath.startsWith('https://') || urlPath.startsWith('//')) {
|
||||
return urlPath;
|
||||
}
|
||||
const base = getBaseUrl();
|
||||
if (urlPath.startsWith('/') && base.endsWith('/')) {
|
||||
return `${base.slice(0, -1)}${urlPath}`;
|
||||
} else if (!urlPath.startsWith('/') && !base.endsWith('/')) {
|
||||
return `${base}/${urlPath}`;
|
||||
}
|
||||
return `${base}${urlPath}`;
|
||||
};
|
||||
|
||||
// Map RomM's native spec to our simplified app interface
|
||||
const mapRomToGame = (apiRom: any): Game => {
|
||||
let coverUrl = 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400';
|
||||
|
||||
if (apiRom.url_cover) {
|
||||
coverUrl = getFullImageUrl(apiRom.url_cover) || coverUrl;
|
||||
} else if (apiRom.url_covers_large && apiRom.url_covers_large.length > 0) {
|
||||
coverUrl = getFullImageUrl(apiRom.url_covers_large[0]) || coverUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(apiRom.id),
|
||||
title: apiRom.name || apiRom.fs_name_no_ext || 'Unknown Title',
|
||||
system: apiRom.platform_display_name || apiRom.platform_slug || 'Unknown Platform',
|
||||
coverUrl,
|
||||
size: apiRom.fs_size_bytes || 0
|
||||
};
|
||||
};
|
||||
|
||||
export const rommApiClient = {
|
||||
get apiBase() {
|
||||
const cleanUrl = getBaseUrl();
|
||||
return cleanUrl.endsWith('/api') ? cleanUrl : `${cleanUrl}/api`;
|
||||
},
|
||||
|
||||
get token() {
|
||||
return localStorage.getItem('romm_token');
|
||||
},
|
||||
|
||||
get headers() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
},
|
||||
|
||||
async login(username: string, password: string): Promise<any> {
|
||||
const data = new URLSearchParams();
|
||||
data.append('username', username);
|
||||
data.append('password', password);
|
||||
data.append('grant_type', 'password');
|
||||
// Ensure we request the explicit permissions RomM FastAPI needs
|
||||
data.append('scope', 'me.read roms.read collections.read assets.read platforms.read');
|
||||
|
||||
const res = await fetch(`${this.apiBase}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: data
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Authentication failed');
|
||||
const json = await res.json();
|
||||
localStorage.setItem('romm_token', json.access_token);
|
||||
return json;
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('romm_token');
|
||||
},
|
||||
|
||||
async fetchGames(): Promise<Game[]> {
|
||||
// We use this as a stand-in for 'Continue Playing'. Fetching last played games.
|
||||
const res = await fetch(`${this.apiBase}/roms?last_played=true&limit=10`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch last played network data.');
|
||||
const json = await res.json();
|
||||
return json.items ? json.items.map(mapRomToGame) : [];
|
||||
},
|
||||
|
||||
async fetchRecentGames(): Promise<Game[]> {
|
||||
// Ordering by internal id desc is functionally identical to created_at logic natively
|
||||
const res = await fetch(`${this.apiBase}/roms?limit=20&order_by=id&order_dir=desc`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch recents.');
|
||||
const json = await res.json();
|
||||
return json.items ? json.items.map(mapRomToGame) : [];
|
||||
},
|
||||
|
||||
async fetchFavorites(): Promise<Game[]> {
|
||||
const res = await fetch(`${this.apiBase}/roms?favorite=true&limit=20`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch favorites.');
|
||||
const json = await res.json();
|
||||
return json.items ? json.items.map(mapRomToGame) : [];
|
||||
},
|
||||
|
||||
async fetchCollections(): Promise<RommCollection[]> {
|
||||
const res = await fetch(`${this.apiBase}/collections`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch collections meta.');
|
||||
const collections = await res.json();
|
||||
|
||||
// Concurrently fetch the games arrays for each populated collection to supply UI with hydration
|
||||
const mapped = await Promise.all(collections.map(async (c: any) => {
|
||||
let gamesItems: Game[] = [];
|
||||
try {
|
||||
const gamesRes = await fetch(`${this.apiBase}/roms?collection_id=${c.id}&limit=20`, { headers: this.headers });
|
||||
if (gamesRes.ok) {
|
||||
const gamesJson = await gamesRes.json();
|
||||
gamesItems = gamesJson.items ? gamesJson.items.map(mapRomToGame) : [];
|
||||
}
|
||||
} catch (e) { console.error("Could not fetch collection games", e) }
|
||||
|
||||
const coverUrl = c.url_cover ? getFullImageUrl(c.url_cover) : undefined;
|
||||
|
||||
// Currently extrapolating that public featured collections represent admin
|
||||
let role: "admin" | "user" = "user";
|
||||
if (c.name.toLowerCase() === 'featured' && c.is_public) {
|
||||
role = "admin";
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(c.id),
|
||||
name: c.name,
|
||||
ownerRole: role,
|
||||
games: gamesItems,
|
||||
coverUrl
|
||||
};
|
||||
}));
|
||||
|
||||
return mapped;
|
||||
},
|
||||
|
||||
async fetchCurrentUser(): Promise<UserProfile> {
|
||||
const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch user profile.');
|
||||
const json = await res.json();
|
||||
|
||||
// RomM obfuscates user assets using hex-encoded IDs (e.g. "User:1" -> "557365723a31")
|
||||
const hexId = Array.from(`User:${json.id}`).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('');
|
||||
const ts = new Date().getTime(); // Auto-cache bust like official UI
|
||||
const constructedAvatarUrl = `${getBaseUrl()}/assets/romm/assets/users/${hexId}/profile/avatar.png?ts=${ts}`;
|
||||
|
||||
return {
|
||||
id: String(json.id),
|
||||
username: json.username || 'Unknown',
|
||||
avatarUrl: constructedAvatarUrl,
|
||||
roleName: json.role?.role_name || json.role?.name || String(json.role) || 'User',
|
||||
};
|
||||
}
|
||||
};
|
||||
44
src/components/CollectionCard.tsx
Normal file
44
src/components/CollectionCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { MagicCard } from "./MagicCard";
|
||||
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
|
||||
|
||||
export const CollectionCard = ({
|
||||
name,
|
||||
caption,
|
||||
coverUrl,
|
||||
icon
|
||||
}: {
|
||||
name: string;
|
||||
caption: string;
|
||||
coverUrl?: string;
|
||||
icon?: string;
|
||||
}) => {
|
||||
const { ref, focused } = useFocusableAutoScroll({
|
||||
onEnterPress: () => console.log('Opening collection', name)
|
||||
});
|
||||
|
||||
return (
|
||||
<MagicCard
|
||||
ref={ref}
|
||||
className={`w-56 shrink-0 cursor-pointer group flex flex-col p-3 rounded-[12px] bg-[#1c1f2c] shadow-[0_10px_30px_rgba(0,0,0,0.2)] transition-all z-10 hover:z-20 ${focused ? "scale-105 ring-2 ring-[#2563eb] z-30" : ""}`}
|
||||
>
|
||||
<div className="aspect-[2/3] bg-white/5 overflow-hidden mb-3 rounded-[8px] border border-white/5 shadow-inner relative flex flex-col items-center justify-center transition-colors duration-300 group-hover:bg-[#2563eb]/10">
|
||||
{coverUrl ? (
|
||||
<img
|
||||
alt={name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
src={coverUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[#c3c6d7]/30 text-5xl group-hover:text-[#2563eb] group-hover:scale-110 transition-all duration-500" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
{icon || 'inventory_2'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-1 flex flex-col relative z-20">
|
||||
<h4 className="text-sm font-black text-white truncate geist-mono uppercase tracking-tight">{name}</h4>
|
||||
<p className="text-[10px] text-[#2563eb] uppercase geist-mono tracking-widest font-bold mt-1 opacity-80">{caption}</p>
|
||||
</div>
|
||||
</MagicCard>
|
||||
);
|
||||
};
|
||||
131
src/components/FeaturedHero.tsx
Normal file
131
src/components/FeaturedHero.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
|
||||
import { Game } from '../api/client';
|
||||
|
||||
const FocusablePoster = ({ coverUrl }: { coverUrl: string }) => {
|
||||
const { ref, focused } = useFocusableAutoScroll();
|
||||
return (
|
||||
<div ref={ref} className={`w-[400px] aspect-[3/4] shrink-0 bg-white/5 overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.3)] transition-all duration-500 ease-out ${focused ? 'scale-105 ring-4 ring-[#2563eb] shadow-2xl z-20' : ''}`}>
|
||||
<img alt="Hero Poster" className="w-full h-full object-cover" src={coverUrl} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FocusableButton = ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => {
|
||||
const { ref, focused } = useFocusableAutoScroll({ onEnterPress: () => onClick?.() });
|
||||
return (
|
||||
<button ref={ref} onClick={onClick} className={`${className} transition-all duration-300 ${focused ? 'scale-105 ring-2 ring-[#2563eb] shadow-xl z-10' : ''}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FocusableThumb = ({ g, isActive, onClick }: { g: Game, isActive: boolean, onClick: () => void }) => {
|
||||
const { ref, focused } = useFocusableAutoScroll({ onEnterPress: onClick });
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={`carousel-thumb w-[90px] shrink-0 aspect-[2/3] overflow-hidden cursor-pointer transition-all duration-300 ring-1 ring-white/10 bg-white/5 rounded-[6px] ${
|
||||
isActive ? 'opacity-100 ring-2 ring-[#2563eb]' : 'opacity-60 hover:opacity-100'
|
||||
} ${focused ? 'scale-110 ring-2 ring-white z-10 shadow-2xl' : ''}`}
|
||||
>
|
||||
<img className="w-full h-full object-cover" src={g.coverUrl} alt="Thumbnail poster" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FeaturedHero = ({ games, isFallback }: { games: Game[], isFallback?: boolean }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused || !games?.length) return;
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % games.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [games, isPaused]);
|
||||
|
||||
if (!games?.length) return null;
|
||||
const game = games[currentIndex];
|
||||
|
||||
const formattedSize = (game.size / (1024 * 1024)).toFixed(2) + " MB";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="bg-[#10131f] border-b border-white/5"
|
||||
id="featured-section"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
<div className="px-12 py-12">
|
||||
<div className="flex gap-12 items-stretch max-w-full">
|
||||
{/* Large Hero Poster */}
|
||||
<FocusablePoster coverUrl={game.coverUrl} />
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col justify-between min-w-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-[#2563eb]/20 text-[#b4c5ff] mb-4 w-fit border border-[#2563eb]/30 rounded-md">
|
||||
<span className="material-symbols-outlined text-[10px]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
{isFallback ? 'history' : 'star'}
|
||||
</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest geist-mono">
|
||||
{isFallback ? 'Recently Added' : 'Featured'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className="text-[72px] font-black text-white tracking-tighter mb-4 leading-[0.9] uppercase geist-mono truncate"
|
||||
title={game.title}
|
||||
>
|
||||
{game.title}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-10 mb-8 geist-mono">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Platform</span>
|
||||
<span className="text-xs font-black text-white">{game.system}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-[#c3c6d7] uppercase tracking-[0.2em] font-bold mb-1 opacity-60">Size</span>
|
||||
<span className="text-xs font-black text-white">{formattedSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[#c3c6d7] leading-relaxed max-w-2xl mb-8 geist-mono">
|
||||
Experience the magic of {game.title} perfectly emulated and synced natively by the Digital Atelier architecture.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<FocusableButton className="bg-gradient-to-br from-[#2563eb] to-[#6001d1] hover:opacity-90 text-white h-11 px-8 font-black flex items-center gap-2 active:scale-95 text-[11px] uppercase geist-mono rounded-[12px] shadow-[0_10px_30px_rgba(37,99,235,0.1)]">
|
||||
<span className="material-symbols-outlined text-base" style={{ fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
|
||||
Play Now
|
||||
</FocusableButton>
|
||||
<FocusableButton className="bg-transparent hover:bg-white/10 text-[#2563eb] border border-white/5 h-11 px-8 font-black flex items-center gap-2 text-[11px] uppercase geist-mono rounded-[12px]">
|
||||
<span className="material-symbols-outlined text-base">download</span>
|
||||
Download
|
||||
</FocusableButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Carousel */}
|
||||
<div className="relative w-full overflow-hidden mt-4">
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar scroll-smooth py-2 px-1 -mx-1 -my-2">
|
||||
{games.map((g, i) => (
|
||||
<FocusableThumb
|
||||
key={g.id}
|
||||
g={g}
|
||||
isActive={i === currentIndex}
|
||||
onClick={() => setCurrentIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
31
src/components/GameCard.tsx
Normal file
31
src/components/GameCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MagicCard } from "./MagicCard";
|
||||
import { syncToPC } from "../utils/sync";
|
||||
import { Game } from "../api/client";
|
||||
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
|
||||
|
||||
export const GameCard = ({ game }: { game: Game }) => {
|
||||
const { ref, focused } = useFocusableAutoScroll({
|
||||
onEnterPress: () => syncToPC(game.id, game.title)
|
||||
});
|
||||
|
||||
return (
|
||||
<MagicCard
|
||||
ref={ref}
|
||||
className={`w-56 shrink-0 cursor-pointer group flex flex-col p-3 rounded-[12px] bg-[#1c1f2c] shadow-[0_10px_30px_rgba(0,0,0,0.2)] transition-all z-10 hover:z-20 ${focused ? "scale-105 ring-2 ring-[#2563eb] z-30" : ""}`}
|
||||
onClick={() => syncToPC(game.id, game.title)}
|
||||
>
|
||||
<div className="aspect-[2/3] bg-white/5 overflow-hidden mb-3 rounded-[8px] border border-white/5 shadow-inner">
|
||||
<img
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
src={game.coverUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-1 flex flex-col relative z-20">
|
||||
<h4 className="text-sm font-black text-white truncate geist-mono uppercase tracking-tight">{game.title}</h4>
|
||||
<p className="text-[10px] text-[#2563eb] uppercase geist-mono tracking-widest font-bold mt-1 opacity-80">{game.system}</p>
|
||||
</div>
|
||||
</MagicCard>
|
||||
);
|
||||
};
|
||||
185
src/components/LibraryGrid.tsx
Normal file
185
src/components/LibraryGrid.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useRef } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
|
||||
import { rommApiClient, Game } from '../api/client';
|
||||
import { GameCard } from './GameCard';
|
||||
import { CollectionCard } from './CollectionCard';
|
||||
import { FeaturedHero } from './FeaturedHero';
|
||||
|
||||
const ScrollBumper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="snap-start shrink-0 px-3 py-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScrollableSection = ({ title, children, showViewAll }: { title: string, children: React.ReactNode, showViewAll?: boolean }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (scrollRef.current) {
|
||||
const { current } = scrollRef;
|
||||
const scrollAmount = direction === 'left' ? -current.offsetWidth * 0.75 : current.offsetWidth * 0.75;
|
||||
current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative group/section">
|
||||
<div className="flex items-center justify-between mb-8 border-l-4 border-[#2563eb] pl-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter geist-mono">{title}</h3>
|
||||
<div className="flex items-center gap-1 opacity-60 hover:opacity-100 transition-opacity duration-300">
|
||||
<button onClick={() => scroll('left')} className="hover:text-white text-[#c3c6d7] transition-all h-6 w-6 flex items-center justify-center rounded-full hover:bg-white/10 active:scale-95 border border-transparent hover:border-white/20">
|
||||
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'wght' 300" }}>chevron_left</span>
|
||||
</button>
|
||||
<button onClick={() => scroll('right')} className="hover:text-white text-[#c3c6d7] transition-all h-6 w-6 flex items-center justify-center rounded-full hover:bg-white/10 active:scale-95 border border-transparent hover:border-white/20">
|
||||
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'wght' 300" }}>chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showViewAll && <a className="text-[10px] font-black text-[#2563eb] hover:underline uppercase geist-mono tracking-[0.3em]" href="#">View All</a>}
|
||||
</div>
|
||||
<div ref={scrollRef} className="flex overflow-x-auto no-scrollbar -mx-3 scroll-smooth relative pt-2 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const FocusableContinueCard = ({ game }: { game: Game }) => {
|
||||
const { ref, focused } = useFocusableAutoScroll({ onEnterPress: () => console.log("Continue Game", game.id) });
|
||||
return (
|
||||
<ScrollBumper>
|
||||
<div ref={ref} className={`bg-[#1c1f2c] p-6 flex gap-6 hover:bg-[#272937] transition-all duration-300 cursor-pointer group rounded-[12px] ring-1 w-[420px] shrink-0 shadow-[0_10px_30px_rgba(0,0,0,0.2)] ${focused ? 'scale-105 ring-2 ring-[#2563eb] bg-[#272937] z-10 shadow-2xl' : 'ring-white/5 hover:ring-[#2563eb]/50'}`}>
|
||||
<div className="w-28 aspect-square bg-white/5 overflow-hidden shrink-0 rounded-[8px] border border-white/5 shadow-inner">
|
||||
<img className={`w-full h-full object-cover transition-transform duration-700 ${focused ? 'scale-105' : 'group-hover:scale-105'}`} src={game.coverUrl} alt="Cover" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||
<h4 className="text-sm font-black text-white mb-3 geist-mono uppercase line-clamp-2 truncate whitespace-normal">{game.title}</h4>
|
||||
<div className="flex items-center justify-between text-[10px] text-[#c3c6d7] uppercase geist-mono mb-2 font-bold tracking-widest">
|
||||
<span>84% Complete</span>
|
||||
<span className="text-[#2563eb]">Active</span>
|
||||
</div>
|
||||
<div className="h-1 bg-white/5 overflow-hidden rounded-full mt-auto">
|
||||
<div className="h-full bg-[#2563eb] w-[84%] shadow-[0_0_10px_rgba(37,99,235,0.8)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollBumper>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryGrid = () => {
|
||||
const { ref: gridRef, focusKey: gridFocusKey } = useFocusable();
|
||||
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
{ queryKey: ['recentGames'], queryFn: () => rommApiClient.fetchRecentGames() },
|
||||
{ queryKey: ['collections'], queryFn: () => rommApiClient.fetchCollections() },
|
||||
{ queryKey: ['favorites'], queryFn: () => rommApiClient.fetchFavorites() },
|
||||
{ queryKey: ['playing'], queryFn: () => rommApiClient.fetchGames() }
|
||||
]
|
||||
});
|
||||
|
||||
const isLoading = results.some(r => r.isLoading);
|
||||
const error = results.some(r => r.error);
|
||||
|
||||
if (isLoading) return <div className="text-center py-20 text-white/50 animate-pulse col-span-full geist-mono uppercase">Loading vault data...</div>;
|
||||
if (error) return <div className="text-red-500 col-span-full geist-mono uppercase">Failed to load vault data</div>;
|
||||
|
||||
const recentGames = results[0].data || [];
|
||||
const collections = results[1].data || [];
|
||||
const favorites = results[2].data || [];
|
||||
const continuePlaying = results[3].data || [];
|
||||
|
||||
const featuredCollections = collections.filter((c) => c.name.toLowerCase() === 'featured');
|
||||
const combinedFeaturedGames = Array.from(new Map(featuredCollections.flatMap(c => c.games).map(g => [g.id, g])).values());
|
||||
|
||||
const hasFeatured = combinedFeaturedGames.length > 0;
|
||||
const heroGames = hasFeatured ? combinedFeaturedGames : recentGames;
|
||||
const showRecentSection = hasFeatured;
|
||||
const standardCollections = collections.filter(c => c.name.toLowerCase() !== 'featured');
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={gridFocusKey}>
|
||||
<div ref={gridRef} className="flex flex-col relative w-full h-full">
|
||||
{heroGames.length > 0 && <FeaturedHero games={heroGames} isFallback={!hasFeatured} />}
|
||||
|
||||
<div className="px-12 py-16 space-y-20 pb-24">
|
||||
|
||||
{showRecentSection && recentGames.length > 0 && (
|
||||
<ScrollableSection title="Recently Added" showViewAll>
|
||||
{recentGames.map((game) => (
|
||||
<ScrollBumper key={game.id + "-recent"}>
|
||||
<GameCard game={game} />
|
||||
</ScrollBumper>
|
||||
))}
|
||||
</ScrollableSection>
|
||||
)}
|
||||
|
||||
{continuePlaying.length > 0 && (
|
||||
<ScrollableSection title="Continue Playing">
|
||||
{continuePlaying.slice(0, 5).map((game) => (
|
||||
<FocusableContinueCard key={game.id + "-continue"} game={game} />
|
||||
))}
|
||||
</ScrollableSection>
|
||||
)}
|
||||
|
||||
{favorites.length > 0 && (
|
||||
<ScrollableSection title="Favorites" showViewAll>
|
||||
{favorites.map((game) => (
|
||||
<ScrollBumper key={game.id + "-fav"}>
|
||||
<GameCard game={game} />
|
||||
</ScrollBumper>
|
||||
))}
|
||||
</ScrollableSection>
|
||||
)}
|
||||
|
||||
{standardCollections.length > 0 && (
|
||||
<ScrollableSection title="Collections" showViewAll>
|
||||
{standardCollections.map((col) => {
|
||||
const cover = col.games?.[0]?.coverUrl || '';
|
||||
const gamesCount = col.games?.length || 0;
|
||||
const caption = gamesCount > 0 ? `${gamesCount} Game${gamesCount === 1 ? '' : 's'}` : (col.ownerRole === 'admin' ? 'Public' : 'Private');
|
||||
|
||||
return (
|
||||
<ScrollBumper key={col.id}>
|
||||
<CollectionCard
|
||||
name={col.name}
|
||||
caption={caption}
|
||||
coverUrl={cover}
|
||||
/>
|
||||
</ScrollBumper>
|
||||
);
|
||||
})}
|
||||
</ScrollableSection>
|
||||
)}
|
||||
|
||||
{/* Autogenerated Collections */}
|
||||
<ScrollableSection title="Autogenerated Collections">
|
||||
{[
|
||||
{ id: 'auto-1', name: 'All Games', icon: 'apps' },
|
||||
{ id: 'auto-2', name: 'Favorites', icon: 'star' },
|
||||
{ id: 'auto-3', name: 'Recently Added', icon: 'schedule' },
|
||||
{ id: 'auto-4', name: 'Recently Played', icon: 'history' },
|
||||
{ id: 'auto-5', name: 'Most Played', icon: 'leaderboard' },
|
||||
{ id: 'auto-6', name: 'Never Played', icon: 'videogame_asset_off' },
|
||||
].map((col) => (
|
||||
<ScrollBumper key={col.id}>
|
||||
<CollectionCard
|
||||
name={col.name}
|
||||
caption="Smart Collection"
|
||||
icon={col.icon}
|
||||
/>
|
||||
</ScrollBumper>
|
||||
))}
|
||||
</ScrollableSection>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryGrid;
|
||||
93
src/components/Login.tsx
Normal file
93
src/components/Login.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Input, Button } from '@heroui/react';
|
||||
import { MagicCard } from './MagicCard';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export const Login = () => {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (username.length > 0 && password.length > 0) {
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
const success = await login(username, password);
|
||||
if (!success) {
|
||||
setError(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#10131f] flex text-foreground selection:bg-[#2563eb]/30 overflow-hidden relative items-center justify-center">
|
||||
{/* Background radial gradient glow for atmospheric depth */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-[#2563eb]/10 blur-[100px] pointer-events-none rounded-full" />
|
||||
|
||||
<MagicCard className="w-full max-w-sm rounded-[1.5rem] bg-[#1c1f2c] shadow-[0_20px_50px_rgba(0,0,0,0.3)] ring-1 ring-white/5 z-10 mx-4">
|
||||
<form onSubmit={handleLogin} className="p-10 flex flex-col gap-8 items-center bg-transparent relative z-20">
|
||||
<div className="flex flex-col items-center gap-2 mb-2 w-full text-center">
|
||||
<h1 className="text-2xl font-black text-white tracking-tighter uppercase geist-mono">{import.meta.env.VITE_LIBRARY_TITLE || "Romm"}</h1>
|
||||
<p className="text-[10px] geist-mono uppercase tracking-[0.05em] text-[#c3c6d7] opacity-60">{import.meta.env.VITE_LIBRARY_SUBTITLE || "Game Manager"}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
inputWrapper: "bg-white/5 hover:bg-white/10 group-data-[focus=true]:bg-white/10 focus-within:!bg-white/10 border-none shadow-none ring-0 group-data-[focus=true]:ring-1 group-data-[focus=true]:ring-[#2563eb]/50 transition-all rounded-[0.5rem] h-12",
|
||||
input: "text-white geist-mono text-sm placeholder:text-[#c3c6d7]/50"
|
||||
}}
|
||||
placeholder="Archivist ID"
|
||||
startContent={<span className="material-symbols-outlined text-[#c3c6d7] text-sm pr-2">person</span>}
|
||||
value={username}
|
||||
onValueChange={setUsername}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
classNames={{
|
||||
inputWrapper: "bg-white/5 hover:bg-white/10 group-data-[focus=true]:bg-white/10 focus-within:!bg-white/10 border-none shadow-none ring-0 group-data-[focus=true]:ring-1 group-data-[focus=true]:ring-[#2563eb]/50 transition-all rounded-[0.5rem] h-12",
|
||||
input: "text-white font-sans text-sm tracking-[0.3em] placeholder:text-[#c3c6d7]/50 placeholder:tracking-normal"
|
||||
}}
|
||||
placeholder="Passcode"
|
||||
startContent={<span className="material-symbols-outlined text-[#c3c6d7] text-sm pr-2">lock</span>}
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-500 text-[10px] text-center geist-mono uppercase tracking-widest font-bold mt-2 animate-appearance-in">Invalid credentials</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
className="w-full h-12 bg-gradient-to-br from-[#2563eb] to-[#6001d1] hover:opacity-90 text-white font-black flex items-center justify-center gap-2 active:scale-95 transition-all text-[11px] uppercase geist-mono rounded-[3rem] shadow-[0_10px_30px_rgba(37,99,235,0.1)] mt-2"
|
||||
>
|
||||
{!isLoading && <>Access Vault <span className="material-symbols-outlined text-base" style={{ fontVariationSettings: "'wght' 600" }}>login</span></>}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center gap-4 my-2 opacity-50">
|
||||
<div className="h-[1px] flex-1 bg-gradient-to-r from-transparent via-[#c3c6d7] to-transparent"></div>
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold geist-mono text-[#c3c6d7]">Or</span>
|
||||
<div className="h-[1px] flex-1 bg-gradient-to-r from-transparent via-[#c3c6d7] to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href={`${import.meta.env.VITE_ROMM_BASE_URL || 'http://localhost:8080'}/api/login/openid`}
|
||||
className="w-full h-12 bg-white/5 hover:bg-white/10 text-white border border-white/5 font-black flex items-center justify-center gap-2 active:scale-95 transition-all text-[11px] uppercase geist-mono rounded-[3rem]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">vpn_key</span>
|
||||
Log in with Single Sign-On
|
||||
</Button>
|
||||
</form>
|
||||
</MagicCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
src/components/MagicCard.tsx
Normal file
69
src/components/MagicCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useRef, useState, useImperativeHandle } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export interface MagicCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MagicCard = React.forwardRef<HTMLDivElement, MagicCardProps>(
|
||||
({ children, className = "", ...props }, ref) => {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [opacity, setOpacity] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => divRef.current as HTMLDivElement);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!divRef.current || isFocused) return;
|
||||
const div = divRef.current;
|
||||
const rect = div.getBoundingClientRect();
|
||||
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
setOpacity(1);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
setOpacity(0);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => setOpacity(1);
|
||||
const handleMouseLeave = () => setOpacity(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative overflow-hidden group/card bg-neutral-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute -inset-px rounded-xl opacity-0 transition duration-300 group-hover/card:opacity-100"
|
||||
style={{
|
||||
opacity,
|
||||
background: `radial-gradient(400px circle at ${position.x}px ${position.y}px, rgba(255,255,255,0.1), transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
83
src/components/Settings.tsx
Normal file
83
src/components/Settings.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
export const Settings = () => {
|
||||
return (
|
||||
<div className="px-8 pb-12 pt-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<header className="mb-10">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-[#e0e1f4] mb-2">Vault Console</h1>
|
||||
<p className="text-[#c3c6d7] font-medium">Configure your library experience and server connections.</p>
|
||||
</header>
|
||||
|
||||
{/* Horizontal Tab Bar */}
|
||||
<div className="flex items-center gap-2 mb-10 overflow-x-auto hide-scrollbar bg-[#181b28] p-1.5 rounded-full w-fit">
|
||||
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Profile</button>
|
||||
<button className="px-6 py-2.5 rounded-full text-sm font-bold bg-[#2563eb] text-[#eeefff] shadow-lg shadow-[#2563eb]/20 transition-all">User Interface</button>
|
||||
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Library Management</button>
|
||||
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Metadata Sources</button>
|
||||
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all">Administration</button>
|
||||
<button className="px-6 py-2.5 rounded-full text-sm font-bold text-[#c3c6d7] hover:text-[#e0e1f4] transition-all whitespace-nowrap">Server Stats</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<section className="w-full space-y-6">
|
||||
<div className="bg-[#272937] rounded-lg p-8 shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="material-symbols-outlined text-[#b4c5ff]" style={{ fontVariationSettings: "'FILL' 1" }}>palette</span>
|
||||
<h2 className="text-xl font-bold tracking-tight text-[#e0e1f4]">Visual Identity</h2>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{/* Toggle: Dark Mode */}
|
||||
<div className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="font-bold text-[#e0e1f4]">Enable Dark Mode</p>
|
||||
<p className="text-sm text-[#c3c6d7]">Switch between obsidian and slate themes.</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input defaultChecked className="sr-only peer" type="checkbox" />
|
||||
<div className="w-12 h-6 bg-[#363847] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#2563eb]"></div>
|
||||
</label>
|
||||
</div>
|
||||
{/* Toggle: Notifications */}
|
||||
<div className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="font-bold text-[#e0e1f4]">Show Notifications</p>
|
||||
<p className="text-sm text-[#c3c6d7]">Get alerts for scan completions and updates.</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input className="sr-only peer" type="checkbox" />
|
||||
<div className="w-12 h-6 bg-[#363847] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#2563eb]"></div>
|
||||
</label>
|
||||
</div>
|
||||
{/* Toggle: Glassmorphism */}
|
||||
<div className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="font-bold text-[#e0e1f4]">Glassmorphism Effects</p>
|
||||
<p className="text-sm text-[#c3c6d7]">Enable translucent blurs and layered depth.</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input defaultChecked className="sr-only peer" type="checkbox" />
|
||||
<div className="w-12 h-6 bg-[#363847] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#2563eb]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout Preview Card */}
|
||||
<div className="bg-gradient-to-br from-[#2563eb]/20 to-[#6001d1]/20 rounded-lg p-8 border border-white/5 relative overflow-hidden group">
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-lg font-bold text-white mb-2">Interface Preview</h3>
|
||||
<p className="text-sm text-blue-200/70 mb-6">Current: Ultra-Modern / Editorial</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-12 h-2 bg-white/20 rounded-full"></div>
|
||||
<div className="w-24 h-2 bg-white/40 rounded-full"></div>
|
||||
<div className="w-8 h-2 bg-white/20 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined absolute -right-4 -bottom-4 text-9xl text-white/5 rotate-12 transition-transform group-hover:rotate-0 duration-700">dashboard_customize</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
src/components/Sidebar.tsx
Normal file
90
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { rommApiClient } from '../api/client';
|
||||
import { FocusContext } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { logout } = useAuth();
|
||||
const { data: user } = useQuery({ queryKey: ['currentUser'], queryFn: () => rommApiClient.fetchCurrentUser() });
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Create physical bounding box for Sidebar natively mapping navigation paths
|
||||
const { ref: containerRef, focusKey: sidebarFocusKey } = useFocusableAutoScroll();
|
||||
|
||||
const fallbackName = user?.username || "Admin";
|
||||
const avatarFallback = `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackName)}&background=2563eb&color=fff&bold=true`;
|
||||
const avatarUrl = !imgError && user?.avatarUrl ? user.avatarUrl : avatarFallback;
|
||||
|
||||
const NavItem = ({ path, icon, label, autoFocus }: { path: string, icon: string, label: string, autoFocus?: boolean }) => {
|
||||
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
|
||||
const { ref, focused, focusSelf } = useFocusableAutoScroll({
|
||||
onEnterPress: () => navigate(path),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
focusSelf();
|
||||
}
|
||||
}, [autoFocus, focusSelf]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={path}
|
||||
ref={ref}
|
||||
className={`mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95 rounded-[12px]
|
||||
${isActive
|
||||
? "bg-[#2563eb]/20 text-[#2563eb]"
|
||||
: "text-[#c3c6d7] hover:text-white hover:bg-white/5"
|
||||
} ${focused ? "scale-105 ring-2 ring-[#2563eb] bg-white/10 z-10 shadow-lg" : ""}`}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}>{icon}</span>
|
||||
<span className="geist-mono tracking-tight text-sm uppercase">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={sidebarFocusKey}>
|
||||
<aside ref={containerRef} className="w-64 h-screen fixed left-0 top-0 bg-[#181b28] shadow-[20px_0_50px_rgba(0,0,0,0.3)] flex flex-col py-8 z-[60]">
|
||||
<div className="px-6 mb-10">
|
||||
<h1 className="text-xl font-black text-white tracking-tighter uppercase geist-mono">{import.meta.env.VITE_LIBRARY_TITLE || "Romm"}</h1>
|
||||
<p className="text-[10px] geist-mono uppercase tracking-[0.05em] text-[#c3c6d7] opacity-60">{import.meta.env.VITE_LIBRARY_SUBTITLE || "Game Manager"}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-1">
|
||||
<NavItem path="/" icon="home" label="Home" autoFocus />
|
||||
<NavItem path="/platforms" icon="bookmarks" label="Platforms" />
|
||||
<NavItem path="/collections" icon="library_books" label="Collections" />
|
||||
<NavItem path="/console" icon="videogame_asset" label="Console" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 pt-6">
|
||||
<NavItem path="/settings" icon="settings" label="Settings" />
|
||||
|
||||
<div className="px-6 mt-4 flex items-center gap-3 overflow-hidden">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 overflow-hidden shrink-0 ring-1 ring-white/20">
|
||||
<img
|
||||
alt="User profile avatar"
|
||||
className="w-full h-full object-cover"
|
||||
src={avatarUrl}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col leading-none flex-1 min-w-0">
|
||||
<span className="text-xs font-bold text-white geist-mono truncate">{user?.username || "Loading"}</span>
|
||||
<span className="text-[10px] text-[#2563eb] font-bold uppercase tracking-widest geist-mono truncate">{user?.roleName || "..."}</span>
|
||||
</div>
|
||||
<button onClick={logout} className="text-[#c3c6d7] hover:text-white hover:bg-red-500/20 p-2 shrink-0 rounded-full transition-all active:scale-95 flex items-center justify-center" title="Log Out">
|
||||
<span className="material-symbols-outlined text-sm">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
};
|
||||
51
src/components/TopNav.tsx
Normal file
51
src/components/TopNav.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
export const TopNav = () => {
|
||||
const { mode } = useInputMode();
|
||||
return (
|
||||
<header className="fixed top-0 right-0 left-64 h-16 z-50 bg-[#10131f]/80 backdrop-blur-2xl flex items-center justify-between px-12 border-b border-white/5">
|
||||
<div className="flex items-center gap-6 w-full">
|
||||
<div className="relative flex-1 max-w-md focus-within:ring-1 focus-within:ring-[#2563eb]/50 rounded-full overflow-hidden bg-[#070913] border border-white/5 transition-all outline-none shadow-inner">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-[#c3c6d7] text-sm">search</span>
|
||||
<input className="w-full bg-transparent border-none pl-10 pr-4 py-2 text-[11px] font-bold tracking-widest uppercase text-white placeholder-[#c3c6d7]/50 focus:ring-0 geist-mono outline-none" placeholder="Search the vault..." type="text"/>
|
||||
</div>
|
||||
|
||||
<div className="hidden xl:flex items-center gap-6 ml-4 geist-mono justify-end flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="account_tree">account_tree</span>
|
||||
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">12</span> Platforms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="sports_esports">sports_esports</span>
|
||||
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">1,402</span> Games</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="hard_drive">hard_drive</span>
|
||||
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">2.4 TB</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="save">save</span>
|
||||
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">842</span> Saves</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="history">history</span>
|
||||
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">142</span> States</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[#2563eb] text-sm" data-icon="photo_library">photo_library</span>
|
||||
<span className="text-[11px] uppercase text-[#c3c6d7] font-bold"><span className="text-white">5,103</span> Screens</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-white/10 mx-2"></div>
|
||||
|
||||
{/* Active Hardware Indicator */}
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-white/5 border border-white/5 rounded-full ring-1 ring-white/10 shadow-inner text-[#2563eb]" title={mode === 'gamepad' ? 'Gamepad Active' : 'Mouse/Keyboard Active'}>
|
||||
<span className="material-symbols-outlined text-[16px]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
{mode === 'gamepad' ? 'sports_esports' : 'mouse'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
48
src/context/AuthContext.tsx
Normal file
48
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { rommApiClient } from '../api/client';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
login: (u: string, p: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!rommApiClient.token);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const login = async (user: string, pass: string) => {
|
||||
try {
|
||||
await rommApiClient.login(user, pass);
|
||||
setIsAuthenticated(true);
|
||||
navigate('/');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setIsAuthenticated(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
60
src/context/InputModeContext.tsx
Normal file
60
src/context/InputModeContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type InputMode = 'mouse' | 'gamepad';
|
||||
|
||||
interface InputModeContextType {
|
||||
mode: InputMode;
|
||||
setMode: (mode: InputMode) => void;
|
||||
}
|
||||
|
||||
const InputModeContext = createContext<InputModeContextType | undefined>(undefined);
|
||||
|
||||
export const InputModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [mode, setModeState] = useState<InputMode>('mouse');
|
||||
|
||||
const setMode = (newMode: InputMode) => {
|
||||
if (newMode !== mode) {
|
||||
setModeState(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Spatial Engine Override
|
||||
if (mode === 'mouse') {
|
||||
document.body.classList.remove('gamepad-active');
|
||||
} else {
|
||||
document.body.classList.add('gamepad-active');
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = () => {
|
||||
setMode('mouse');
|
||||
};
|
||||
const onMouseDown = () => {
|
||||
setMode('mouse');
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||
window.addEventListener('mousedown', onMouseDown, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mousedown', onMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InputModeContext.Provider value={{ mode, setMode }}>
|
||||
{children}
|
||||
</InputModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useInputMode = () => {
|
||||
const context = useContext(InputModeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useInputMode must be used within an InputModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
19
src/hooks/useFocusableAutoScroll.ts
Normal file
19
src/hooks/useFocusableAutoScroll.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFocusable, UseFocusableConfig } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
export const useFocusableAutoScroll = (config?: UseFocusableConfig) => {
|
||||
const result = useFocusable(config);
|
||||
const { mode } = useInputMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'gamepad' && result.focused && result.ref.current) {
|
||||
result.ref.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
}
|
||||
}, [result.focused, mode]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
focused: mode === 'gamepad' ? result.focused : false
|
||||
};
|
||||
};
|
||||
110
src/hooks/useGamepad.ts
Normal file
110
src/hooks/useGamepad.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { navigateByDirection } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useInputMode } from '../context/InputModeContext';
|
||||
|
||||
const BUTTON_A = 0;
|
||||
const BUTTON_B = 1;
|
||||
const BUTTON_DPAD_UP = 12;
|
||||
const BUTTON_DPAD_DOWN = 13;
|
||||
const BUTTON_DPAD_LEFT = 14;
|
||||
const BUTTON_DPAD_RIGHT = 15;
|
||||
const AXIS_THRESHOLD = 0.5;
|
||||
const INITIAL_DELAY_MS = 400;
|
||||
const REPEAT_RATE_MS = 100;
|
||||
|
||||
export const useGamepad = () => {
|
||||
const requestRef = useRef<number>();
|
||||
const lastState = useRef<Record<string, boolean>>({});
|
||||
const lastFireTime = useRef<Record<string, number>>({});
|
||||
const { setMode } = useInputMode();
|
||||
|
||||
const handleInput = (id: string, isPressed: boolean, action: () => void, repeatable = true) => {
|
||||
const now = performance.now();
|
||||
const wasPressed = lastState.current[id];
|
||||
|
||||
if (isPressed) {
|
||||
setMode('gamepad');
|
||||
if (!wasPressed) {
|
||||
// Initial press isolated
|
||||
action();
|
||||
lastFireTime.current[id] = now;
|
||||
lastState.current[id] = true;
|
||||
} else if (repeatable) {
|
||||
// Holding press calculates repeat boundaries natively
|
||||
const holdTime = now - lastFireTime.current[id];
|
||||
if (holdTime > INITIAL_DELAY_MS) {
|
||||
action();
|
||||
// Reset tracker mathematically for consecutive rapid fires
|
||||
lastFireTime.current[id] = now - INITIAL_DELAY_MS + REPEAT_RATE_MS;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (wasPressed) {
|
||||
lastFireTime.current[id] = 0;
|
||||
lastState.current[id] = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchEnter = () => {
|
||||
const eventParams = { bubbles: true, cancelable: true };
|
||||
const keydown = new window.KeyboardEvent('keydown', eventParams);
|
||||
Object.defineProperty(keydown, 'keyCode', { get: () => 13 });
|
||||
Object.defineProperty(keydown, 'key', { get: () => 'Enter' });
|
||||
document.dispatchEvent(keydown);
|
||||
window.dispatchEvent(keydown);
|
||||
};
|
||||
|
||||
const dispatchEscape = () => {
|
||||
const eventParams = { bubbles: true, cancelable: true };
|
||||
const keydown = new window.KeyboardEvent('keydown', eventParams);
|
||||
Object.defineProperty(keydown, 'keyCode', { get: () => 27 });
|
||||
Object.defineProperty(keydown, 'key', { get: () => 'Escape' });
|
||||
document.dispatchEvent(keydown);
|
||||
window.dispatchEvent(keydown);
|
||||
};
|
||||
|
||||
const checkGamepad = () => {
|
||||
try {
|
||||
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
|
||||
// Multiplex all gamepads to defeat Ghost Virtual Devices hogs natively occupying slot 0
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gp = gamepads[i];
|
||||
if (!gp) continue;
|
||||
|
||||
const up = gp.buttons[BUTTON_DPAD_UP]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] < -AXIS_THRESHOLD);
|
||||
const down = gp.buttons[BUTTON_DPAD_DOWN]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] > AXIS_THRESHOLD);
|
||||
const left = gp.buttons[BUTTON_DPAD_LEFT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] < -AXIS_THRESHOLD);
|
||||
const right = gp.buttons[BUTTON_DPAD_RIGHT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] > AXIS_THRESHOLD);
|
||||
const enter = gp.buttons[BUTTON_A]?.pressed;
|
||||
const back = gp.buttons[BUTTON_B]?.pressed;
|
||||
|
||||
handleInput(`gp${i}_up`, up, () => navigateByDirection('up', {}));
|
||||
handleInput(`gp${i}_down`, down, () => navigateByDirection('down', {}));
|
||||
handleInput(`gp${i}_left`, left, () => navigateByDirection('left', {}));
|
||||
handleInput(`gp${i}_right`, right, () => navigateByDirection('right', {}));
|
||||
handleInput(`gp${i}_enter`, enter, () => dispatchEnter(), false);
|
||||
handleInput(`gp${i}_back`, back, () => dispatchEscape(), false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Gamepad Polling Error", e);
|
||||
}
|
||||
|
||||
requestRef.current = requestAnimationFrame(checkGamepad);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onConnect = () => console.log("Gamepad Connected Native Callback!");
|
||||
const onDisconnect = () => console.log("Gamepad Disconnected Native Callback!");
|
||||
window.addEventListener("gamepadconnected", onConnect);
|
||||
window.addEventListener("gamepaddisconnected", onDisconnect);
|
||||
|
||||
requestRef.current = requestAnimationFrame(checkGamepad);
|
||||
return () => {
|
||||
if (requestRef.current) cancelAnimationFrame(requestRef.current);
|
||||
window.removeEventListener("gamepadconnected", onConnect);
|
||||
window.removeEventListener("gamepaddisconnected", onDisconnect);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
35
src/index.css
Normal file
35
src/index.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #10131f; /* Adjusted to Stitch's deep charcoal */
|
||||
overflow: hidden; /* Prevent body scroll per Stitch design */
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
body.gamepad-active {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
body.gamepad-active * {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
.geist-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
}
|
||||
42
src/main.tsx
Normal file
42
src/main.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { HeroUIProvider } from '@heroui/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { init } from '@noriginmedia/norigin-spatial-navigation'
|
||||
import { InputModeProvider } from './context/InputModeContext'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
init({
|
||||
debug: false,
|
||||
visualDebug: false
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 60 * 24, // Keep offline disk cache for 24 hours for instant loading
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<HeroUIProvider>
|
||||
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
|
||||
<InputModeProvider>
|
||||
<App />
|
||||
</InputModeProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</HeroUIProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
32
src/utils/sync.ts
Normal file
32
src/utils/sync.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const syncToPC = async (gameId: string, title: string) => {
|
||||
try {
|
||||
if (!('showDirectoryPicker' in window)) {
|
||||
alert("Your browser does not support the File System Access API. Please use a modern Chromium-based browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dirHandle = await (window as any).showDirectoryPicker();
|
||||
console.log(`Syncing ${title} (${gameId}) to`, dirHandle.name);
|
||||
|
||||
// Placeholder logic for downloading unzipped files sequentially
|
||||
const fileHandle = await dirHandle.getFileHandle(`${title.replace(/[^a-z0-9]/gi, '_')}.rom`, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
// In a real app, this would fetch the actual parts of the game
|
||||
// and stream it to the writable. For now, writing a dummy buffer.
|
||||
const dummyData = new TextEncoder().encode("DUMMY ROM DATA CONTENT - IMPLEMENT NATIVE FETCH HERE");
|
||||
await writable.write(dummyData);
|
||||
await writable.close();
|
||||
|
||||
console.log(`Successfully synced ${title} to PC.`);
|
||||
alert(`Successfully synced ${title} to PC.`);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
console.log('User cancelled the directory picker');
|
||||
} else {
|
||||
console.error('Failed to sync directory:', err);
|
||||
alert('Failed to sync to PC. See console for details.');
|
||||
}
|
||||
}
|
||||
};
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly ROMM_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
448
stitch.html
Normal file
448
stitch.html
Normal 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&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"/>
|
||||
<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
310
stitch_settings.html
Normal 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&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"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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
27
tailwind.config.ts
Normal 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
25
tsconfig.json
Normal 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
9
tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user