This commit is contained in:
2026-03-26 11:33:03 +01:00
commit 1f20359fa3
30 changed files with 7849 additions and 0 deletions

157
frontend/src/marathonApi.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { SearchItem, SearchResult, UpgradeSearchResult } from './types';
const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|webp)(?:[?#].*)?$/i;
const IMAGE_FALLBACK_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp'] as const;
interface CatalogResponse {
updatedAt: string;
items: SearchItem[];
upgrades: UpgradeSearchResult[];
}
interface PopularResponse {
picks: SearchResult[];
}
let catalogPromise: Promise<CatalogResponse> | null = null;
function normalizeCatalog(payload: CatalogResponse): CatalogResponse {
const normalizedUpgrades = payload.upgrades.map((upgrade) => {
if (Array.isArray(upgrade.levels)) {
return upgrade;
}
const legacyUpgrade = upgrade as UpgradeSearchResult & { salvage?: UpgradeSearchResult['levels'][number]['salvage'] };
const salvage = Array.isArray(legacyUpgrade.salvage) ? legacyUpgrade.salvage : [];
return {
...upgrade,
levels: [{ level: 1, salvage }],
};
});
return {
...payload,
upgrades: normalizedUpgrades,
};
}
function scoreResult(name: string, query: string): number {
const lowerName = name.toLowerCase();
if (lowerName === query) {
return 0;
}
if (lowerName.startsWith(query)) {
return 1;
}
const index = lowerName.indexOf(query);
if (index >= 0) {
return 2 + index;
}
return Number.POSITIVE_INFINITY;
}
async function fetchCatalog(): Promise<CatalogResponse> {
const response = await fetch('/api/catalog');
if (!response.ok) {
throw new Error(`Failed to fetch catalog: ${response.status}`);
}
const payload = (await response.json()) as CatalogResponse;
if (!Array.isArray(payload.items) || !Array.isArray(payload.upgrades)) {
throw new Error('Invalid catalog payload');
}
return normalizeCatalog(payload);
}
async function getCatalog(): Promise<CatalogResponse> {
if (!catalogPromise) {
catalogPromise = fetchCatalog().catch((error: unknown) => {
catalogPromise = null;
throw error;
});
}
return catalogPromise;
}
export function buildImageSearchCandidates(iconUrl: string): string[] {
if (!iconUrl) {
return [];
}
if (IMAGE_EXTENSION_RE.test(iconUrl)) {
return [iconUrl];
}
const candidates = [iconUrl, ...IMAGE_FALLBACK_EXTENSIONS.map((ext) => `${iconUrl}.${ext}`)];
return Array.from(new Set(candidates));
}
export async function getAllItems(): Promise<SearchItem[]> {
const catalog = await getCatalog();
return catalog.items;
}
export async function searchResultsByName(query: string, limit = 5): Promise<SearchResult[]> {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return [];
}
const catalog = await getCatalog();
const candidates: SearchResult[] = [
...catalog.items.filter((item) => item.name.toLowerCase().includes(normalizedQuery)),
...catalog.upgrades.filter((upgrade) => upgrade.name.toLowerCase().includes(normalizedQuery)),
];
return candidates
.sort((a, b) => {
const scoreDiff = scoreResult(a.name, normalizedQuery) - scoreResult(b.name, normalizedQuery);
if (scoreDiff !== 0) {
return scoreDiff;
}
return a.name.localeCompare(b.name);
})
.slice(0, limit);
}
export async function getPopularResults(limit = 5): Promise<SearchResult[]> {
const response = await fetch(`/api/popularity?limit=${Math.max(1, Math.floor(limit))}`);
if (!response.ok) {
throw new Error(`Failed to fetch popular picks: ${response.status}`);
}
const payload = (await response.json()) as PopularResponse;
if (!Array.isArray(payload.picks)) {
throw new Error('Invalid popular picks payload');
}
return payload.picks;
}
export async function trackResultAdded(result: SearchResult): Promise<void> {
const payload = {
type: 'isUpgrade' in result ? 'upgrade' : 'item',
slug: result.slug,
};
try {
await fetch('/api/popularity/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
} catch {
// Ignore analytics failures so add-to-list UX is never blocked.
}
}