commit 37e349d3e3cc2a8bd4fb4c59c9094ab6ff1b1474 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Fri Mar 27 13:13:28 2026 -0400 removed junk diff --git a/src/api/client.ts b/src/api/client.ts index a5d20b5..a398ab6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -142,6 +142,10 @@ export interface UserProfile { // Function to safely extract base URL const getBaseUrl = () => { + // During development, if we're on localhost, we prefer relative paths to leverage the Vite proxy and avoid CORS. + if (import.meta.env.DEV && window.location.hostname === 'localhost') { + return ''; + } const rawUrl = import.meta.env.VITE_ROMM_BASE_URL || 'http://localhost:8080'; const cleanUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl; return cleanUrl; @@ -192,6 +196,7 @@ const formatDate = (unixSeconds: any) => { export const rommApiClient = { get apiBase() { const cleanUrl = getBaseUrl(); + if (cleanUrl === '') return '/api'; return cleanUrl.endsWith('/api') ? cleanUrl : `${cleanUrl}/api`; }, @@ -437,6 +442,9 @@ export const rommApiClient = { ...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), ...(getFirst('screenshots') || []) ])).filter(Boolean) as string[], + notes: json.all_user_notes || [], + saves: json.user_saves || [], + states: json.user_states || [], }; }, commit 2bb3c150f54917f5423faf00243ba87e553784a0 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Thu Mar 26 22:13:07 2026 -0400 pretty much completed game metadata area diff --git a/src/api/client.ts b/src/api/client.ts index cee0f0a..a5d20b5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -24,6 +24,18 @@ export interface RAAchievement { badge_id: string | null; } +export interface HLTBMetadata { + main_story: number; + main_plus_extra: number; + completionist: number; + all_styles?: number; + main_story_count?: number; + main_plus_extra_count?: number; + completionist_count?: number; + all_styles_count?: number; + image?: string; +} + export interface RomRAMetadata { first_release_date?: number | null; genres?: string[]; @@ -74,6 +86,40 @@ export interface DetailedGame extends Game { youtubeId?: string; // YouTube ID for embed ra_id?: number; merged_ra_metadata?: RomRAMetadata; + hltb_id?: number; + hltb_metadata?: HLTBMetadata; + notes?: RomNote[]; + saves?: RomSave[]; + states?: RomState[]; +} + +export interface RomSave { + id: number; + file_name: string; + file_size_bytes: number; + emulator: string; + slot?: number; + created_at: string; + updated_at: string; +} + +export interface RomState { + id: number; + file_name: string; + emulator: string; + created_at: string; + updated_at: string; +} + +export interface RomNote { + id: number; + title: string; + content: string; + is_public: boolean; + tags?: string[]; + username: string; + created_at: string; + updated_at: string; } export interface RommCollection { @@ -384,6 +430,8 @@ export const rommApiClient = { youtubeId: json.youtube_video_id || json.igdb_metadata?.youtube_video_id || json.launchbox_metadata?.youtube_video_id, ra_id: json.ra_id, merged_ra_metadata: json.merged_ra_metadata, + hltb_id: json.hltb_id, + hltb_metadata: json.hltb_metadata, screenshots: Array.from(new Set([ ...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)), ...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), @@ -521,5 +569,23 @@ export const rommApiClient = { ra_username: json.ra_username, ra_progression: json.ra_progression, }; + }, + + async fetchRomSaves(id: string): Promise { + const res = await fetch(`${this.apiBase}/saves?rom_id=${id}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch saves.'); + return res.json(); + }, + + async fetchRomStates(id: string): Promise { + const res = await fetch(`${this.apiBase}/states?rom_id=${id}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch states.'); + return res.json(); + }, + + async fetchRomNotes(id: string): Promise { + const res = await fetch(`${this.apiBase}/roms/${id}/notes`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch notes.'); + return res.json(); } }; commit 7ccc48ae0f5f849ea3fe26199f0349ace782175b Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Thu Mar 26 07:13:42 2026 -0400 retro achievement integration diff --git a/src/api/client.ts b/src/api/client.ts index 16c6fa0..cee0f0a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -14,6 +14,43 @@ export interface Platform { iconUrl?: string; } +export interface RAAchievement { + ra_id: number | null; + title: string | null; + description: string | null; + points: number | null; + num_awarded: number | null; + num_awarded_hardcore: number | null; + badge_id: string | null; +} + +export interface RomRAMetadata { + first_release_date?: number | null; + genres?: string[]; + companies?: string[]; + achievements: RAAchievement[]; +} + +export interface EarnedAchievement { + id: string; + date: string; + date_hardcore?: string; +} + +export interface RAProgressionResult { + rom_ra_id: number | null; + max_possible: number | null; + num_awarded: number | null; + num_awarded_hardcore: number | null; + most_recent_awarded_date?: string | null; + earned_achievements: EarnedAchievement[]; +} + +export interface RAProgression { + total: number; + results: RAProgressionResult[]; +} + export interface DetailedGame extends Game { summary?: string; developers?: string[]; @@ -35,6 +72,8 @@ export interface DetailedGame extends Game { platformId?: number; videoUrl?: string; // Direct video link youtubeId?: string; // YouTube ID for embed + ra_id?: number; + merged_ra_metadata?: RomRAMetadata; } export interface RommCollection { @@ -51,6 +90,8 @@ export interface UserProfile { username: string; avatarUrl?: string; roleName: string; + ra_username?: string | null; + ra_progression?: RAProgression | null; } // Function to safely extract base URL @@ -341,6 +382,8 @@ export const rommApiClient = { platformId: json.platform_id, videoUrl: getFullImageUrl(json.url_video || json.ss_metadata?.video_url), youtubeId: json.youtube_video_id || json.igdb_metadata?.youtube_video_id || json.launchbox_metadata?.youtube_video_id, + ra_id: json.ra_id, + merged_ra_metadata: json.merged_ra_metadata, screenshots: Array.from(new Set([ ...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)), ...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), @@ -475,6 +518,8 @@ export const rommApiClient = { username: json.username || 'Unknown', avatarUrl: constructedAvatarUrl, roleName: json.role?.role_name || json.role?.name || String(json.role) || 'User', + ra_username: json.ra_username, + ra_progression: json.ra_progression, }; } }; commit 3d593b83f05a4035fbc24a705d2f54d4f57bafd8 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Wed Mar 25 21:18:44 2026 -0400 more filling out of game page diff --git a/src/api/client.ts b/src/api/client.ts index ed0df15..16c6fa0 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -33,6 +33,8 @@ export interface DetailedGame extends Game { favorite?: boolean; manualUrl?: string; platformId?: number; + videoUrl?: string; // Direct video link + youtubeId?: string; // YouTube ID for embed } export interface RommCollection { @@ -309,7 +311,6 @@ export const rommApiClient = { genres: getAll('genres').map(sanitize).filter(Boolean), franchises: getAll('franchises').map(sanitize).filter(Boolean), releaseDate: formatDate(json.release_date || getFirst('first_release_date') || getFirst('release_date')), - screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), fsName: json.fs_name, regions: regions.length > 0 ? regions : ['Global'], players: (() => { @@ -338,6 +339,13 @@ export const rommApiClient = { favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'), manualUrl: getFullImageUrl(json.url_manual), platformId: json.platform_id, + videoUrl: getFullImageUrl(json.url_video || json.ss_metadata?.video_url), + youtubeId: json.youtube_video_id || json.igdb_metadata?.youtube_video_id || json.launchbox_metadata?.youtube_video_id, + screenshots: Array.from(new Set([ + ...(json.merged_screenshots || []).map((s: string) => getFullImageUrl(s)), + ...(json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), + ...(getFirst('screenshots') || []) + ])).filter(Boolean) as string[], }; }, commit 2efd6cae9a334b36caf98ee7f192fdd09ab8215a Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Wed Mar 25 20:34:53 2026 -0400 more game page stuff diff --git a/src/api/client.ts b/src/api/client.ts index c1089cf..ed0df15 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -32,6 +32,7 @@ export interface DetailedGame extends Game { collections?: string[]; favorite?: boolean; manualUrl?: string; + platformId?: number; } export interface RommCollection { @@ -311,7 +312,22 @@ export const rommApiClient = { screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), fsName: json.fs_name, regions: regions.length > 0 ? regions : ['Global'], - players: json.players || getFirst('total_players') || getFirst('players'), + players: (() => { + let val = json.metadatum?.player_count || json.players || getFirst('player_count') || getFirst('total_players') || getFirst('players') || getFirst('max_players'); + if (val && (String(val) === '1' || String(val).toLowerCase() === 'single player')) { + const modes = getAll('game_modes').map(sanitize).map(m => m.toLowerCase()); + if (modes.includes('multiplayer') || modes.includes('co-operative') || modes.includes('split screen')) { + val = 'Multiplayer'; + } + } + if (val) { + val = String(val); + if (!val.toLowerCase().includes('player')) { + val = `${val} ${val === '1' ? 'Player' : 'Players'}`; + } + } + return val; + })(), rating: ratingVal ? Math.round(Number(ratingVal)) : undefined, esrbRating: ageRatingStr, sha1: json.hashes?.sha1, @@ -321,6 +337,7 @@ export const rommApiClient = { ])).filter(Boolean), favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'), manualUrl: getFullImageUrl(json.url_manual), + platformId: json.platform_id, }; }, @@ -422,6 +439,19 @@ export const rommApiClient = { return res.json(); }, + getPlayUrl(gameId: string): string { + return `${this.apiBase}/roms/${gameId}/play`; + }, + + getManualUrl(gameId: string, platformId?: number): string { + // Constructed manual URL: /assets/romm/resources/roms/[platform_id]/[rom_id]/manual/[rom_id].pdf + // We return a relative path to ensure the request goes through our local origin (bypassing CORS via Vite proxy). + if (platformId) { + return `/assets/romm/resources/roms/${platformId}/${gameId}/manual/${gameId}.pdf`; + } + return `${this.apiBase}/roms/${gameId}/manual`; + }, + async fetchCurrentUser(): Promise { const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers }); if (!res.ok) throw new Error('Failed to fetch user profile.'); commit 4e6ce29f0b13a61e9bf9e91c037f660f92653a27 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Wed Mar 25 16:08:46 2026 -0400 more game page development diff --git a/src/api/client.ts b/src/api/client.ts index bbf231d..c1089cf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -30,11 +30,14 @@ export interface DetailedGame extends Game { ratingIconUrl?: string; // Icon for Age Rating sha1?: string; collections?: string[]; + favorite?: boolean; + manualUrl?: string; } export interface RommCollection { id: string; name: string; + is_favorite?: boolean; ownerRole: 'admin' | 'user'; games: Game[]; coverUrl?: string; // RomM collections can have intrinsic covers @@ -119,7 +122,7 @@ export const rommApiClient = { 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'); + data.append('scope', 'me.read roms.read roms.user.write collections.read collections.write assets.read platforms.read'); const res = await fetch(`${this.apiBase}/token`, { method: 'POST', @@ -216,6 +219,11 @@ export const rommApiClient = { return Array.from(new Set(all)).filter(Boolean); }; + const sanitize = (val: any): string => { + if (typeof val !== 'string') return String(val?.name || val?.display_name || val || ''); + return val.trim().replace(/;+$/, ''); + }; + // Extract Age Ratings from all providers (array format for IGDB/ScreenScraper) const ageRatings = new Set(); for (const p of providers) { @@ -254,7 +262,7 @@ export const rommApiClient = { // 1. Root lists const rootList = type === 'developer' ? (json.developers || json.companies) : (json.publishers || json.companies); if (Array.isArray(rootList)) { - rootList.forEach(c => companies.add(typeof c === 'string' ? c : (c.name || c.display_name))); + rootList.forEach(c => companies.add(sanitize(c))); } // 2. Provider metadata @@ -262,20 +270,20 @@ export const rommApiClient = { // IGDB involved_companies if (Array.isArray(p.involved_companies)) { p.involved_companies.forEach((ic: any) => { - if (ic[type] && ic.company?.name) companies.add(ic.company.name); + if (ic[type] && ic.company?.name) companies.add(sanitize(ic.company.name)); }); } // General companies/developers/publishers lists const pList = p.companies || (type === 'developer' ? (p.developers || p.developer_companies) : (p.publishers || p.publisher_companies)); if (Array.isArray(pList)) { - pList.forEach((c: any) => companies.add(typeof c === 'string' ? c : (c.name || c.display_name))); + pList.forEach((c: any) => companies.add(sanitize(c))); } } // 3. Root singulars const rootSingular = type === 'developer' ? (json.developer || json.developer_name) : (json.publisher || json.publisher_name); - if (rootSingular && typeof rootSingular === 'string') companies.add(rootSingular); + if (rootSingular && typeof rootSingular === 'string') companies.add(sanitize(rootSingular)); return Array.from(companies).filter(Boolean); }; @@ -297,8 +305,8 @@ export const rommApiClient = { summary: json.summary || getFirst('summary') || getFirst('description'), developers: devList, publishers: pubList, - genres: getAll('genres').map(g => typeof g === 'string' ? g : (g.name || g.display_name)), - franchises: getAll('franchises').map(f => typeof f === 'string' ? f : (f.name || f.display_name)), + genres: getAll('genres').map(sanitize).filter(Boolean), + franchises: getAll('franchises').map(sanitize).filter(Boolean), releaseDate: formatDate(json.release_date || getFirst('first_release_date') || getFirst('release_date')), screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), fsName: json.fs_name, @@ -311,6 +319,8 @@ export const rommApiClient = { ...(json.rom_collections || []).map((c: any) => c.name), ...(getFirst('collections') || []) ])).filter(Boolean), + favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'), + manualUrl: getFullImageUrl(json.url_manual), }; }, @@ -341,6 +351,7 @@ export const rommApiClient = { return { id: String(c.id), name: c.name, + is_favorite: c.is_favorite || false, ownerRole: role, games: gamesItems, coverUrl @@ -350,6 +361,67 @@ export const rommApiClient = { return mapped; }, + async fetchCollectionDetails(collectionId: string): Promise { + const res = await fetch(`${this.apiBase}/collections/${collectionId}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch collection details.'); + return res.json(); + }, + + async updateCollection(collectionId: string, data: { name: string; description?: string; rom_ids: number[] }): Promise { + const formData = new FormData(); + formData.append('name', data.name); + formData.append('description', data.description || ''); + formData.append('rom_ids', JSON.stringify(data.rom_ids)); + + const res = await fetch(`${this.apiBase}/collections/${collectionId}?is_public=false&remove_cover=false`, { + method: 'PUT', + headers: this.headers, + body: formData + }); + if (!res.ok) throw new Error('Failed to update collection.'); + return res.json(); + }, + + async toggleFavorite(gameId: string, favorite: boolean): Promise { + // 1. Find the Favorites collection + const collections = await this.fetchCollections(); + const favCol = collections.find(c => c.is_favorite); + if (!favCol) throw new Error("Could not find Favorites collection"); + + // 2. Fetch current member IDs (full list for collection-tier update) + const details = await this.fetchCollectionDetails(favCol.id); + let ids: number[] = details.rom_ids || []; + + // 3. Toggle membership + const numId = parseInt(gameId); + if (favorite) { + if (!ids.includes(numId)) ids.push(numId); + } else { + ids = ids.filter(id => id !== numId); + } + + // 4. Persistence via Multipart-Encoded PUT + return this.updateCollection(favCol.id, { + name: favCol.name || 'Favorites', + rom_ids: ids + }); + }, + + async createCollection(name: string, description: string = ''): Promise { + const formData = new FormData(); + formData.append('name', name); + formData.append('description', description); + formData.append('rom_ids', '[]'); // Start empty + + const res = await fetch(`${this.apiBase}/collections?is_public=false`, { + method: 'POST', + headers: this.headers, + body: formData + }); + if (!res.ok) throw new Error('Failed to create collection.'); + return res.json(); + }, + async fetchCurrentUser(): Promise { const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers }); if (!res.ok) throw new Error('Failed to fetch user profile.'); commit d2e976a14529acded3efb87797782737fad1a954 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Tue Mar 24 22:37:24 2026 -0400 game page refinements diff --git a/src/api/client.ts b/src/api/client.ts index 4e2fd8d..bbf231d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -16,11 +16,20 @@ export interface Platform { export interface DetailedGame extends Game { summary?: string; - developer?: string; - publisher?: string; + developers?: string[]; + publishers?: string[]; genres?: string[]; + franchises?: string[]; // New field releaseDate?: string; screenshots?: string[]; + fsName?: string; + regions?: string[]; + players?: string; + rating?: number; // New field + esrbRating?: string; + ratingIconUrl?: string; // Icon for Age Rating + sha1?: string; + collections?: string[]; } export interface RommCollection { @@ -79,6 +88,14 @@ const formatBytes = (bytes: number) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; +const formatDate = (unixSeconds: any) => { + if (!unixSeconds) return undefined; + const val = Number(unixSeconds); + if (isNaN(val) || val < 1000000) return String(unixSeconds); + const date = new Date(val * 1000); + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date); +}; + export const rommApiClient = { get apiBase() { const cleanUrl = getBaseUrl(); @@ -173,14 +190,127 @@ export const rommApiClient = { if (!res.ok) throw new Error('Failed to fetch game details.'); const json = await res.json(); const game = mapRomToGame(json); + + // Extract metadata with fallbacks across all providers + const providers = [ + json.latest_igdb_metadata, json.igdb_metadata, + json.latest_moby_metadata, json.moby_metadata, + json.latest_screenscraper_metadata, json.screenscraper_metadata, + json.latest_launchbox_metadata, json.launchbox_metadata, + json.latest_steamgrid_metadata, json.steamgrid_metadata + ].filter(Boolean); + + const getFirst = (key: string) => { + for (const p of providers) { + if (p[key] !== undefined && p[key] !== null) return p[key]; + } + return json[key]; + }; + + const getAll = (key: string) => { + const all: any[] = []; + if (Array.isArray(json[key])) all.push(...json[key]); + for (const p of providers) { + if (Array.isArray(p[key])) all.push(...p[key]); + } + return Array.from(new Set(all)).filter(Boolean); + }; + + // Extract Age Ratings from all providers (array format for IGDB/ScreenScraper) + const ageRatings = new Set(); + for (const p of providers) { + if (Array.isArray(p.age_ratings)) { + p.age_ratings.forEach((ar: any) => { + if (ar.rating) ageRatings.add(String(ar.rating)); + }); + } + const pRating = p.esrb_rating || p.esrb || p.pegi_rating || p.pegi; + if (pRating && typeof pRating === 'string') ageRatings.add(pRating); + } + + // Filter out placeholders like "Not Rated" or "Unknown" if we have valid ratings + let finalRatings = Array.from(ageRatings).filter(Boolean); + const placeholders = ['not rated', 'unknown', 'pending', 'nr', 'rp', 'rating pending']; + if (finalRatings.some(r => !placeholders.includes(r.toLowerCase()))) { + finalRatings = finalRatings.filter(r => !placeholders.includes(r.toLowerCase())); + } + + const ageRatingStr = finalRatings.join(' / ') || (json.esrb_rating || json.esrb); + + // Defensive mapping for regions + const regionsRaw = [ + ...(json.rom_regions || []), + ...(json.region ? [json.region] : []), + ...(getFirst('regions') || []) + ]; + + const regions = Array.from(new Set(regionsRaw.map((r: any) => + typeof r === 'string' ? r : (r.display_name || r.name || String(r)) + ))).filter(Boolean); + + const extractCompanies = (type: 'developer' | 'publisher') => { + const companies = new Set(); + + // 1. Root lists + const rootList = type === 'developer' ? (json.developers || json.companies) : (json.publishers || json.companies); + if (Array.isArray(rootList)) { + rootList.forEach(c => companies.add(typeof c === 'string' ? c : (c.name || c.display_name))); + } + + // 2. Provider metadata + for (const p of providers) { + // IGDB involved_companies + if (Array.isArray(p.involved_companies)) { + p.involved_companies.forEach((ic: any) => { + if (ic[type] && ic.company?.name) companies.add(ic.company.name); + }); + } + + // General companies/developers/publishers lists + const pList = p.companies || (type === 'developer' ? (p.developers || p.developer_companies) : (p.publishers || p.publisher_companies)); + if (Array.isArray(pList)) { + pList.forEach((c: any) => companies.add(typeof c === 'string' ? c : (c.name || c.display_name))); + } + } + + // 3. Root singulars + const rootSingular = type === 'developer' ? (json.developer || json.developer_name) : (json.publisher || json.publisher_name); + if (rootSingular && typeof rootSingular === 'string') companies.add(rootSingular); + + return Array.from(companies).filter(Boolean); + }; + + const devList = extractCompanies('developer'); + const pubList = extractCompanies('publisher'); + + // Rating extraction with ScreenScraper/Launchbox fallbacks + let ratingVal = getFirst('rating') || getFirst('total_rating') || getFirst('aggregated_rating') || getFirst('ss_score') || getFirst('community_rating'); + if (ratingVal && String(ratingVal).includes('.')) { + const parsed = parseFloat(String(ratingVal)); + // Normalize 0-10 or 0-5 scales to 100% + if (parsed <= 10) ratingVal = parsed * 10; + else if (parsed <= 5) ratingVal = parsed * 20; + } + return { ...game, - summary: json.summary, - developer: json.developer, - publisher: json.publisher, - genres: json.genres, - releaseDate: json.release_date, - screenshots: (json.screenshots || []).map((s: any) => getFullImageUrl(s.url) || '') + summary: json.summary || getFirst('summary') || getFirst('description'), + developers: devList, + publishers: pubList, + genres: getAll('genres').map(g => typeof g === 'string' ? g : (g.name || g.display_name)), + franchises: getAll('franchises').map(f => typeof f === 'string' ? f : (f.name || f.display_name)), + releaseDate: formatDate(json.release_date || getFirst('first_release_date') || getFirst('release_date')), + screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''), + fsName: json.fs_name, + regions: regions.length > 0 ? regions : ['Global'], + players: json.players || getFirst('total_players') || getFirst('players'), + rating: ratingVal ? Math.round(Number(ratingVal)) : undefined, + esrbRating: ageRatingStr, + sha1: json.hashes?.sha1, + collections: Array.from(new Set([ + ...(json.rom_collections || []).map((c: any) => c.name), + ...(getFirst('collections') || []) + ])).filter(Boolean), }; }, commit 5fe789a8fd64d073228f0d8516086e1b293dff8a Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Mon Mar 23 22:24:45 2026 -0400 basic games page layout diff --git a/src/api/client.ts b/src/api/client.ts index b8c42af..4e2fd8d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,8 +2,25 @@ export interface Game { id: string; title: string; system: string; + size: string; coverUrl: string; - size: number; +} + +export interface Platform { + id: number; + name: string; + slug: string; + romCount: number; + iconUrl?: string; +} + +export interface DetailedGame extends Game { + summary?: string; + developer?: string; + publisher?: string; + genres?: string[]; + releaseDate?: string; + screenshots?: string[]; } export interface RommCollection { @@ -45,23 +62,23 @@ const getFullImageUrl = (urlPath: string | undefined): string | undefined => { // 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', + title: apiRom.name || apiRom.fs_name, system: apiRom.platform_display_name || apiRom.platform_slug || 'Unknown Platform', - coverUrl, - size: apiRom.fs_size_bytes || 0 + size: formatBytes(apiRom.fs_size_bytes || 0), + coverUrl: getFullImageUrl(apiRom.url_cover) || 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400', }; }; +const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +}; + export const rommApiClient = { get apiBase() { const cleanUrl = getBaseUrl(); @@ -131,6 +148,42 @@ export const rommApiClient = { return res.json(); }, + async fetchPlatforms(): Promise { + const res = await fetch(`${this.apiBase}/platforms`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch platforms.'); + const json = await res.json(); + return json.map((p: any) => ({ + id: p.id, + name: p.display_name || p.name || p.fs_slug, + slug: p.fs_slug, + romCount: p.rom_count || 0, + iconUrl: getFullImageUrl(p.url_logo) + })); + }, + + async fetchGamesByPlatform(platformId: number): Promise { + const res = await fetch(`${this.apiBase}/roms?platform_ids=${platformId}&limit=100`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch platform games.'); + const json = await res.json(); + return json.items ? json.items.map(mapRomToGame) : []; + }, + + async fetchGameDetails(gameId: string): Promise { + const res = await fetch(`${this.apiBase}/roms/${gameId}`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch game details.'); + const json = await res.json(); + const game = mapRomToGame(json); + return { + ...game, + summary: json.summary, + developer: json.developer, + publisher: json.publisher, + genres: json.genres, + releaseDate: json.release_date, + screenshots: (json.screenshots || []).map((s: any) => getFullImageUrl(s.url) || '') + }; + }, + async fetchCollections(): Promise { const res = await fetch(`${this.apiBase}/collections`, { headers: this.headers }); if (!res.ok) throw new Error('Failed to fetch collections meta.'); commit 4e0b510d7f6d0b414ef8abc2152a8fce63d66f25 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Mon Mar 23 17:09:47 2026 -0400 fixed some navigation issues diff --git a/src/api/client.ts b/src/api/client.ts index 15a7776..b8c42af 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -112,15 +112,14 @@ export const rommApiClient = { }, async fetchRecentGames(): Promise { - // 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 }); + const res = await fetch(`${this.apiBase}/roms?limit=50&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 { - const res = await fetch(`${this.apiBase}/roms?favorite=true&limit=20`, { headers: this.headers }); + const res = await fetch(`${this.apiBase}/roms?favorite=true&limit=50`, { 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) : []; commit f6ae56c8487a4cd1723ebda34d4005966929f0c6 Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Mon Mar 23 16:43:09 2026 -0400 fixed little controller issue and wired up stats in top bar diff --git a/src/api/client.ts b/src/api/client.ts index 264f987..15a7776 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -126,6 +126,12 @@ export const rommApiClient = { return json.items ? json.items.map(mapRomToGame) : []; }, + async fetchStats(): Promise { + const res = await fetch(`${this.apiBase}/stats`, { headers: this.headers }); + if (!res.ok) throw new Error('Failed to fetch stats.'); + return res.json(); + }, + async fetchCollections(): Promise { const res = await fetch(`${this.apiBase}/collections`, { headers: this.headers }); if (!res.ok) throw new Error('Failed to fetch collections meta.'); commit 9e8f148a10dd0ea63d7c7893c1a863282c1d25ef Author: roormonger <34205054+roormonger@users.noreply.github.com> Date: Mon Mar 23 15:31:41 2026 -0400 feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..264f987 --- /dev/null +++ b/src/api/client.ts @@ -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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + 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', + }; + } +};