fix(ui): make header search states explicit with slow-day timeouts
The search dropdown toggled blindly before the async call settled and only handled success, so API failures left a blank dropdown that looked like search did nothing. - Replace toggle with explicit open/close, Escape and outside-click to dismiss, and disabled search button while a request is in flight - Add isSearching/searchError/hasSearched states: loading text, failure panel with message plus Retry, empty prompt, and a no-results message for the searched term - Guard null coverImage/title with a romaji/native fallback and a keyed each block; ignore stale late responses via request ids - Tolerate slow AniList days: slow notice at 8s, 30s Promise.race fail-safe (backend allows 20s), and an immediate offline message via navigator.onLine so the UI never spins indefinitely
This commit is contained in:
@@ -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<void> {
|
||||
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<typeof setTimeout> | null = null
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
slowTimer = setTimeout(() => {
|
||||
if (searchRequestId === myRequest && isSearching) {
|
||||
showSlowNotice = true
|
||||
}
|
||||
}, SLOW_NOTICE_MS)
|
||||
const timeout = new Promise<never>((_, 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()
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleWindowClick} />
|
||||
|
||||
<div id="searchDropdown" class="relative w-64 md:w-48">
|
||||
<div class="flex">
|
||||
@@ -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/>
|
||||
<button id="aniListSearchButton"
|
||||
class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium rounded-e-lg border focus:ring-4 focus:outline-none bg-blue-600 hover:bg-blue-700 focus:ring-blue-800"
|
||||
class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium rounded-e-lg border focus:ring-4 focus:outline-none bg-blue-600 hover:bg-blue-700 focus:ring-blue-800 disabled:opacity-50"
|
||||
disabled={isSearching}
|
||||
on:click={() => {
|
||||
searchDropdown()
|
||||
if(aniSearch.length > 0) runAniListSearch()
|
||||
runAniListSearch()
|
||||
}}>
|
||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
@@ -54,31 +163,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="aniListSearchDropdown" class="z-10 absolute left-0 hidden bg-white rounded-lg shadow w-60 2xl:w-80 dark:bg-gray-700">
|
||||
{#if aniListSearchActive}
|
||||
<div id="aniListSearchDropdown" class:hidden={!dropdownOpen} class="z-10 absolute left-0 bg-white rounded-lg shadow w-60 2xl:w-80 dark:bg-gray-700">
|
||||
{#if isSearching}
|
||||
<div class="m-4 text-gray-700 dark:text-gray-200">{showSlowNotice ? "AniList is slow today, still trying..." : "Searching AniList..."}</div>
|
||||
{:else if searchError}
|
||||
<div class="m-4">
|
||||
<p class="text-red-600 dark:text-red-400 font-medium">Search failed</p>
|
||||
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200">{searchError}</p>
|
||||
<button class="mt-3 text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
on:click={() => runAniListSearch()}>
|
||||
Retry search
|
||||
</button>
|
||||
</div>
|
||||
{:else if !hasSearched && aniSearch.trim().length === 0}
|
||||
<div class="m-4 text-gray-700 dark:text-gray-200">Please enter a search term...</div>
|
||||
{:else if resultCount() > 0 && aniListSearch}
|
||||
<ul class="h-56 w-full py-2 overflow-y-auto text-gray-700 dark:text-gray-200"
|
||||
aria-labelledby="aniListSearchButton">
|
||||
{#each aniListSearch.data.Page.media as media}
|
||||
{#each aniListSearch.data.Page.media as media (media.id)}
|
||||
<li class="w-full">
|
||||
<div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg">
|
||||
<button on:click={() => {
|
||||
searchDropdown()
|
||||
push(`#/anime/${media.id}`)
|
||||
}}
|
||||
<button on:click={() => goToAnime(media.id)}
|
||||
>
|
||||
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media.coverImage.large}
|
||||
alt="{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english} Cover">
|
||||
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media?.coverImage?.large}
|
||||
alt="{displayTitle(media)} Cover">
|
||||
</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => {
|
||||
searchDropdown()
|
||||
push(`#/anime/${media.id}`)
|
||||
}} >{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english }</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => goToAnime(media.id)} >{displayTitle(media)}</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if aniSearch.length === 0}
|
||||
<div class="m-4">Please enter a search term...</div>
|
||||
{:else if hasSearched}
|
||||
<div class="m-4 text-gray-700 dark:text-gray-200">No results found for "{aniSearch.trim()}". Try a different title.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user