Files
marathon-todo/frontend/src/marathonApi.ts
Oleksandr Shuryha 124de9f5b8
Some checks failed
Redeploy Docker Compose / redeploy (push) Failing after 2s
Add locations to todo items and automate Gitea deploy
2026-03-26 18:44:33 +01:00

195 lines
5.1 KiB
TypeScript

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 normalizeKnownLocations(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized = value
.map((location) => (typeof location === 'string' ? location.trim() : ''))
.filter((location) => Boolean(location));
return normalized.length > 0 ? normalized : undefined;
}
function normalizeCatalog(payload: CatalogResponse): CatalogResponse {
const normalizedItems = payload.items.map((item) => ({
...item,
knownLocations: normalizeKnownLocations(item.knownLocations),
}));
const normalizedUpgrades = payload.upgrades.map((upgrade) => {
if (Array.isArray(upgrade.levels)) {
return {
...upgrade,
levels: upgrade.levels.map((level) => ({
...level,
salvage: Array.isArray(level.salvage)
? level.salvage.map((salvageItem) => ({
...salvageItem,
knownLocations: normalizeKnownLocations(salvageItem.knownLocations),
}))
: [],
})),
};
}
const legacyUpgrade = upgrade as UpgradeSearchResult & { salvage?: UpgradeSearchResult['levels'][number]['salvage'] };
const salvage = Array.isArray(legacyUpgrade.salvage) ? legacyUpgrade.salvage : [];
return {
...upgrade,
levels: [
{
level: 1,
salvage: salvage.map((salvageItem) => ({
...salvageItem,
knownLocations: normalizeKnownLocations(salvageItem.knownLocations),
})),
},
],
};
});
return {
...payload,
items: normalizedItems,
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.
}
}