feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings
This commit is contained in:
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',
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user