1071 lines
80 KiB
Plaintext
1071 lines
80 KiB
Plaintext
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<RomSave[]> {
|
|
+ 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<RomState[]> {
|
|
+ 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<RomNote[]> {
|
|
+ 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<UserProfile> {
|
|
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<string>();
|
|
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<any> {
|
|
+ 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<any> {
|
|
+ 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<any> {
|
|
+ // 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<any> {
|
|
+ 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<UserProfile> {
|
|
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<string>();
|
|
+ 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<string>();
|
|
+
|
|
+ // 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<Platform[]> {
|
|
+ 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<Game[]> {
|
|
+ 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<DetailedGame> {
|
|
+ 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<RommCollection[]> {
|
|
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<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 });
|
|
+ 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<Game[]> {
|
|
- 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<any> {
|
|
+ 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<RommCollection[]> {
|
|
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<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',
|
|
+ };
|
|
+ }
|
|
+};
|