Files
mal/static/search/fetch.ts

202 lines
5.0 KiB
TypeScript

import type { SearchItem, SearchResponse } from "./state";
import {
setClearButtonState,
clearResults,
renderEmptyState,
renderSearchErrorState,
renderItems,
appendItems,
} from "./render";
import {
searchInput,
searchResults,
responseCache,
getFetchTimeout,
setFetchTimeout,
setLastQuery,
getLastQuery,
getActiveRequestController,
setActiveRequestController,
setSearchPagination,
getNextSearchPage,
hasNextSearchPage,
isFetchingNextPage,
setFetchingNextPage,
} from "./state";
const parseSearchResponse = (payload: unknown): SearchResponse => {
if (Array.isArray(payload)) {
return { items: payload as SearchItem[], hasNextPage: false };
}
if (payload && typeof payload === "object" && Array.isArray((payload as SearchResponse).items)) {
const response = payload as SearchResponse;
return {
items: response.items,
hasNextPage: response.hasNextPage,
nextPage: response.nextPage,
};
}
return { items: [], hasNextPage: false };
};
const visibleSearchItems = (items: SearchItem[], query: string): SearchItem[] => {
if (query === "") {
return [];
}
return items.filter((item) => item.type === "anime");
};
const renderPendingQuery = (query: string): void => {
if (!query) {
return;
}
clearResults();
};
export const cancelScheduledFetch = (): void => {
const fetchTimeout = getFetchTimeout();
if (!fetchTimeout) {
return;
}
window.clearTimeout(fetchTimeout);
setFetchTimeout(undefined);
};
export const fetchSearchItems = (query: string): void => {
setLastQuery(query);
setClearButtonState(query !== "");
const activeRequestController = getActiveRequestController();
if (activeRequestController) {
activeRequestController.abort();
setActiveRequestController(undefined);
}
if (query === "") {
clearResults();
renderEmptyState("");
return;
}
const cached = responseCache.get(query);
if (cached) {
setSearchPagination(cached.nextPage, cached.hasNextPage);
renderItems(visibleSearchItems(cached.items, query));
return;
}
renderPendingQuery(query);
const controller = new AbortController();
setActiveRequestController(controller);
fetch(`/api/search?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 !== getLastQuery()) {
return;
}
const response = parseSearchResponse(payload);
const visibleItems = visibleSearchItems(response.items, query);
setActiveRequestController(undefined);
setSearchPagination(response.nextPage, response.hasNextPage);
responseCache.set(query, response);
renderItems(visibleItems);
})
.catch((error) => {
if (controller.signal.aborted) {
return;
}
setActiveRequestController(undefined);
console.error("search request failed:", error);
renderSearchErrorState(query);
});
};
const fetchNextSearchPage = (): void => {
const query = getLastQuery();
const page = getNextSearchPage();
if (!query || !hasNextSearchPage() || !page || isFetchingNextPage()) {
return;
}
setFetchingNextPage(true);
fetch(`/api/search?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 !== getLastQuery()) {
return;
}
const response = parseSearchResponse(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,
});
}
setSearchPagination(response.nextPage, response.hasNextPage);
appendItems(visibleItems);
})
.catch((error) => {
window.showToast?.({ message: "Failed to load more search results." });
console.error("failed to load more search results:", error);
})
.finally(() => {
setFetchingNextPage(false);
});
};
export const onResultsScroll = (): void => {
if (!searchResults) {
return;
}
const remainingScroll =
searchResults.scrollHeight - searchResults.scrollTop - searchResults.clientHeight;
if (remainingScroll < 480) {
fetchNextSearchPage();
}
};
export const scheduleFetch = (): void => {
const fetchTimeout = getFetchTimeout();
if (fetchTimeout) {
window.clearTimeout(fetchTimeout);
}
const query = searchInput?.value.trim() || "";
setClearButtonState(query !== "");
setFetchTimeout(
window.setTimeout(
() => {
fetchSearchItems(query);
},
query.length >= 2 ? 240 : 80,
),
);
};