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

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

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

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