202 lines
5.0 KiB
TypeScript
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,
|
|
),
|
|
);
|
|
};
|