diff --git a/frontend/src/helperComponents/Search.svelte b/frontend/src/helperComponents/Search.svelte index 5a7c4f6..742ad5c 100644 --- a/frontend/src/helperComponents/Search.svelte +++ b/frontend/src/helperComponents/Search.svelte @@ -5,23 +5,128 @@ import {push} from "svelte-spa-router"; let aniSearch = "" - let aniListSearch: AniSearchList - let aniListSearchActive = false + let aniListSearch: AniSearchList | null = null + let dropdownOpen = false + let isSearching = false + let showSlowNotice = false + let searchError: string | null = null + let hasSearched = false + let searchRequestId = 0 - function runAniListSearch(): void { - AniListSearch(aniSearch).then(result => { - aniListSearch = result - aniListSearchActive = true - }) + const SLOW_NOTICE_MS = 8000 + const SEARCH_TIMEOUT_MS = 30000 + + function openDropdown(): void { + dropdownOpen = true } - function searchDropdown(): void { - let dropdown = document.querySelector("#aniListSearchDropdown") - dropdown.classList.toggle("hidden") + function closeDropdown(): void { + dropdownOpen = false + } + + function displayTitle(media: { title?: { english?: string | null; romaji?: string | null; native?: string | null } }): string { + const english = media?.title?.english + if (english !== undefined && english !== null && english !== "") { + return english + } + const romaji = media?.title?.romaji + if (romaji !== undefined && romaji !== null && romaji !== "") { + return romaji + } + return media?.title?.native ?? "Unknown title" + } + + function resultCount(): number { + const media = aniListSearch?.data?.Page?.media + return Array.isArray(media) ? media.length : 0 + } + + async function runAniListSearch(): Promise { + const term = aniSearch.trim() + openDropdown() + if (term.length === 0) { + searchRequestId += 1 + aniListSearch = null + searchError = null + hasSearched = false + isSearching = false + showSlowNotice = false + return + } + if (isSearching) { + return + } + if (typeof navigator !== "undefined" && !navigator.onLine) { + aniListSearch = null + hasSearched = true + searchError = "You appear to be offline. Check your connection and try again." + return + } + isSearching = true + showSlowNotice = false + searchError = null + hasSearched = true + const myRequest = searchRequestId + 1 + searchRequestId = myRequest + let slowTimer: ReturnType | null = null + let timeoutTimer: ReturnType | null = null + try { + slowTimer = setTimeout(() => { + if (searchRequestId === myRequest && isSearching) { + showSlowNotice = true + } + }, SLOW_NOTICE_MS) + const timeout = new Promise((_, reject) => { + timeoutTimer = setTimeout(() => { + reject(new Error("AniList is taking too long to respond. Please try again.")) + }, SEARCH_TIMEOUT_MS) + }) + const result = await Promise.race([AniListSearch(term), timeout]) + if (searchRequestId !== myRequest) { + return + } + aniListSearch = result as AniSearchList + if (!Array.isArray(aniListSearch?.data?.Page?.media)) { + aniListSearch = null + } + } catch (e) { + if (searchRequestId !== myRequest) { + return + } + aniListSearch = null + searchError = e instanceof Error ? e.message : String(e) + } finally { + if (slowTimer !== null) { + clearTimeout(slowTimer) + } + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer) + } + if (searchRequestId === myRequest) { + isSearching = false + showSlowNotice = false + } + } + } + + function goToAnime(id: number): void { + closeDropdown() + push(`#/anime/${id}`) + } + + function handleWindowClick(e: MouseEvent): void { + if (!dropdownOpen) { + return + } + const container = document.querySelector("#searchDropdown") + if (container && e.target instanceof Node && !container.contains(e.target)) { + closeDropdown() + } } +
@@ -33,16 +138,20 @@ placeholder="Search for Anime" on:keypress={(e) => { if (e.key === "Enter") { - searchDropdown() - if(aniSearch.length > 0) runAniListSearch() + runAniListSearch() + } + }} + on:keydown={(e) => { + if (e.key === "Escape") { + closeDropdown() } }} required/>
- \ No newline at end of file +