Files
mal/static/search/fetch.ts
2026-06-13 22:29:23 +02:00

193 lines
4.8 KiB
TypeScript

import type { CommandPaletteItem, CommandPaletteResponse } from "./state";
import { state, searchInput, searchResults, responseCache } from "./state";
import {
setClearButtonState,
clearResults,
renderEmptyState,
renderItems,
appendItems,
} from "./render";
const parseCommandPaletteResponse = (payload: unknown): CommandPaletteResponse => {
if (Array.isArray(payload)) {
return { items: payload as CommandPaletteItem[], hasNextPage: false };
}
if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as CommandPaletteResponse).items)
) {
const response = payload as CommandPaletteResponse;
return {
items: response.items,
hasNextPage: response.hasNextPage,
nextPage: response.nextPage,
};
}
return { items: [], hasNextPage: false };
};
const visibleSearchItems = (items: CommandPaletteItem[], query: string): CommandPaletteItem[] => {
if (query === "") {
return [];
}
return items.filter((item) => item.type === "anime");
};
const renderPendingQuery = (query: string): void => {
if (!query) {
return;
}
clearResults();
};
export const cancelScheduledFetch = (): void => {
if (!state.fetchTimeout) {
return;
}
window.clearTimeout(state.fetchTimeout);
state.fetchTimeout = undefined;
};
export const fetchSearchItems = (query: string): void => {
state.lastQuery = query;
setClearButtonState(query !== "");
if (state.activeRequestController) {
state.activeRequestController.abort();
state.activeRequestController = undefined;
}
if (query === "") {
clearResults();
renderEmptyState("");
return;
}
const cached = responseCache.get(query);
if (cached) {
state.nextSearchPage = cached.nextPage;
state.hasNextSearchPage = cached.hasNextPage;
renderItems(visibleSearchItems(cached.items, query));
return;
}
renderPendingQuery(query);
const controller = new AbortController();
state.activeRequestController = controller;
fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal })
.then((res: Response) => {
if (!res.ok) {
return { items: [], hasNextPage: false };
}
return res.json();
})
.then((payload: unknown) => {
if (controller.signal.aborted || query !== state.lastQuery) {
return;
}
const response = parseCommandPaletteResponse(payload);
const visibleItems = visibleSearchItems(response.items, query);
state.activeRequestController = undefined;
state.nextSearchPage = response.nextPage;
state.hasNextSearchPage = response.hasNextPage;
responseCache.set(query, response);
renderItems(visibleItems);
})
.catch((err: unknown) => {
if (controller.signal.aborted) {
return;
}
state.activeRequestController = undefined;
console.error("Search overlay error:", err);
renderItems([]);
});
};
export const fetchNextSearchPage = (): void => {
if (
!state.lastQuery ||
!state.hasNextSearchPage ||
!state.nextSearchPage ||
state.isFetchingNextPage
) {
return;
}
state.isFetchingNextPage = true;
const query = state.lastQuery;
const page = state.nextSearchPage;
fetch(
"/api/command-palette?q=" +
encodeURIComponent(query) +
"&page=" +
encodeURIComponent(String(page)),
)
.then((res: Response) => {
if (!res.ok) {
return { items: [], hasNextPage: false };
}
return res.json();
})
.then((payload: unknown) => {
if (query !== state.lastQuery) {
return;
}
const response = parseCommandPaletteResponse(payload);
const visibleItems = visibleSearchItems(response.items, query);
const cached = responseCache.get(query);
if (cached) {
responseCache.set(query, {
items: [...cached.items, ...response.items],
hasNextPage: response.hasNextPage,
nextPage: response.nextPage,
});
}
state.nextSearchPage = response.nextPage;
state.hasNextSearchPage = response.hasNextPage;
appendItems(visibleItems);
})
.catch((err: unknown) => {
console.error("Search overlay pagination error:", err);
})
.finally(() => {
state.isFetchingNextPage = false;
});
};
export const onResultsScroll = (): void => {
if (!searchResults) {
return;
}
const remainingScroll =
searchResults.scrollHeight - searchResults.scrollTop - searchResults.clientHeight;
if (remainingScroll < 480) {
fetchNextSearchPage();
}
};
export const scheduleFetch = (): void => {
if (state.fetchTimeout) {
window.clearTimeout(state.fetchTimeout);
}
const query = searchInput?.value.trim() || "";
setClearButtonState(query !== "");
state.fetchTimeout = window.setTimeout(
() => fetchSearchItems(query),
query.length >= 2 ? 240 : 80,
);
};