From 5dbb04dbdd6f7f68c50a92bcd07cb3dc30f34a03 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 13 Jun 2026 22:29:23 +0200 Subject: [PATCH] feat: add search/fetch.ts --- static/search/fetch.ts | 192 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 static/search/fetch.ts diff --git a/static/search/fetch.ts b/static/search/fetch.ts new file mode 100644 index 0000000..2a87a40 --- /dev/null +++ b/static/search/fetch.ts @@ -0,0 +1,192 @@ +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, + ); +};