Some checks failed
Redeploy Docker Compose / redeploy (push) Failing after 2s
195 lines
5.1 KiB
TypeScript
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.
|
|
}
|
|
}
|