initial
This commit is contained in:
157
frontend/src/marathonApi.ts
Normal file
157
frontend/src/marathonApi.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user