Compare commits

...
5 Commits
Author SHA1 Message Date
john-okeefe 0cca48adb1 chore(release): bump version to 1.6.5 2026-09-03 19:12:18 -04:00
john-okeefe d2f2f8a618 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
2026-09-03 19:09:44 -04:00
john-okeefe fcbcda7eac fix(anilist): harden AniList query transport and search errors
AniListQuery previously ignored json.Marshal/http.NewRequest errors,
logged the wrong variable on client.Do failure, and dereferenced a
nil response body, which could panic and leave callers hanging.

- Return early with user-safe messages on encode/request failures
- Add 20s http.Client timeout and nil guards for res/res.Body
- Keep logs generic so no auth material is ever printed
- Add aniListGraphQLErrorMessage/aniListSearchStatusError helpers
  mapping 429 to a rate-limit retry message, 5xx to a temporary
  outage message, and GraphQL errors[] (even on HTTP 200) to a
  surfaced message
- AniListSearch rejects empty bodies and unparseable payloads with
  actionable errors instead of failing silently downstream
2026-09-03 19:09:40 -04:00
john-okeefe 0a89ec3652 fix(anilist): decode array-shaped airingSchedule/relations and isolate per-service sync errors
The 1.6.1 fix exposed a latent decoding bug: AniList returns
airingSchedule.nodes and relations.nodes as arrays, but the Go Media
struct declared Nodes as single structs. Every full-media response
failed json.Unmarshal partway through - the update path turned that
into a rejected promise which skipped the MAL and Simkl syncs and all
table updates, while page loads silently continued with partially
decoded data (the reason the old tags/genres copy workaround existed).

- Media.AiringSchedule.Nodes is now []AiringScheduleNode and
  Media.Relations is an exported []MediaRelation (previously an
  unexported field silently dropped by encoding/json); title and
  fuzzy-date sub-structs promoted to named types.
- Regenerate wailsjs models for the new shapes.
- Anime.svelte: handleSubmit and deleteEntries now wrap each service
  in its own try/catch surfaced via setApiError/ErrorModal, so one
  service failing can no longer skip the others; removed the obsolete
  tags/genres copy workaround.
- AniListUpdateEntry/AniListDeleteEntry log HTTP status and response
  body on failure for terminal diagnostics.

Bump productVersion to 1.6.2.
2026-09-01 20:18:42 -04:00
john-okeefe 5a14863a8f fix(anilist): repair SaveMediaListEntry mutation and surface update failures
The AniListUpdateEntry mutation was mangled in 54c109a when the
standardized media field block was pasted in without the media { }
wrapper: media-level fields (idMal, title, ...) sat directly on
SaveMediaListEntry (which returns MediaList), the MediaList fields
(status, startedAt, ..., user) ended up at the Mutation root, and a
stray closing brace made the document a guaranteed 400. Because the
response status was discarded, the frontend received a zeroed
AniListGetSingleAnime and blanked the anime page after every submit,
making AniList appear logged out (and the change was never saved).

- Restore the mutation to match the tested bruno request: id/mediaId/
  userId, standard media block inside media { }, MediaList fields
  inside the selection, balanced braces.
- AniListUpdateEntry and AniListDeleteEntry now return an error on
  403/non-200/unparseable responses (mirroring
  GetAniListUserWatchingList) instead of silently returning zero
  values.
- Anime.svelte guards against replacing the page data with an empty
  response and raises the API error modal instead.

Bump productVersion to 1.6.1.
2026-09-01 19:49:47 -04:00
6 changed files with 650 additions and 208 deletions
+201 -88
View File
@@ -7,26 +7,42 @@ import (
"io"
"log"
"net/http"
"strings"
"time"
)
func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
reader, _ := json.Marshal(body)
response, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
reader, err := json.Marshal(body)
if err != nil {
log.Printf("Failed at response, %s\n", err)
log.Printf("AniList request failed: could not encode request body\n")
return nil, "Could not prepare the AniList request."
}
request, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
if err != nil {
log.Printf("AniList request failed: could not create request\n")
return nil, "Could not reach AniList. Please check your connection and try again."
}
if login && (AniListJWT{}) != aniListJwt {
response.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
request.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
} else if login {
return nil, "Please login to AniList to make this request"
}
response.Header.Add("Content-Type", "application/json")
response.Header.Add("Accept", "application/json")
request.Header.Add("Content-Type", "application/json")
request.Header.Add("Accept", "application/json")
client := &http.Client{}
res, resErr := client.Do(response)
client := &http.Client{Timeout: 20 * time.Second}
res, resErr := client.Do(request)
if resErr != nil {
log.Printf("Failed at res, %s\n", err)
log.Printf("AniList request failed: network error\n")
return nil, "Could not reach AniList. Please check your connection and try again."
}
if res == nil {
log.Printf("AniList request failed: empty response\n")
return nil, "Could not reach AniList. Please check your connection and try again."
}
if res.Body == nil {
log.Printf("AniList request failed: empty response body\n")
return nil, "Could not reach AniList. Please check your connection and try again."
}
defer res.Body.Close()
@@ -39,6 +55,37 @@ func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
return returnedBody, res.Status
}
func aniListGraphQLErrorMessage(returnedBody json.RawMessage) string {
var gqlErr struct {
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(returnedBody, &gqlErr); err != nil {
return ""
}
if len(gqlErr.Errors) > 0 && strings.TrimSpace(gqlErr.Errors[0].Message) != "" {
return strings.TrimSpace(gqlErr.Errors[0].Message)
}
return ""
}
func aniListSearchStatusError(status string, returnedBody json.RawMessage) error {
if msg := aniListGraphQLErrorMessage(returnedBody); msg != "" {
return fmt.Errorf("AniList search failed: %s", msg)
}
if strings.Contains(status, "429") {
return fmt.Errorf("AniList is rate-limiting search right now. Please wait a moment and try again.")
}
if strings.Contains(status, "500") || strings.Contains(status, "502") || strings.Contains(status, "503") || strings.Contains(status, "504") {
return fmt.Errorf("AniList is temporarily unavailable (%s). Please try again shortly.", status)
}
if strings.HasPrefix(status, "Could not") || strings.HasPrefix(status, "Please login") {
return fmt.Errorf("%s", status)
}
return fmt.Errorf("AniList search failed (%s). Please try again.", status)
}
func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
user := a.GetAniListLoggedInUser()
@@ -338,12 +385,18 @@ func (a *App) AniListSearch(query string) (interface{}, error) {
}
returnedBody, status := AniListQuery(body, false)
if status != "200 OK" {
return nil, fmt.Errorf("API search failed with status: %s", status)
return nil, aniListSearchStatusError(status, returnedBody)
}
if len(returnedBody) == 0 {
return nil, fmt.Errorf("AniList returned an empty response. Please try again.")
}
if msg := aniListGraphQLErrorMessage(returnedBody); msg != "" {
return nil, fmt.Errorf("AniList search failed: %s", msg)
}
var post interface{}
err := json.Unmarshal(returnedBody, &post)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
log.Printf("Failed at unmarshal search results\n")
return nil, fmt.Errorf("Failed to parse search results")
}
return post, nil
@@ -558,7 +611,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) (An
return post, nil
}
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime {
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) (AniListGetSingleAnime, error) {
body := struct {
Query string `json:"query"`
Variables AniListUpdateVariables `json:"variables"`
@@ -584,84 +637,87 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
startedAt: $startedAt
completedAt: $completedAt
) {
id
idMal
title {
userPreferred
romaji
english
native
}
description
coverImage {
extraLarge
large
medium
color
}
bannerImage
format
season
seasonYear
status
episodes
duration
countryOfOrigin
source
synonyms
averageScore
meanScore
popularity
trending
favourites
isFavourite
relations {
nodes {
id
mediaId
userId
media {
id
idMal
title {
userPreferred
romaji
english
native
}
description
coverImage {
extraLarge
large
medium
color
}
bannerImage
format
season
seasonYear
status
episodes
duration
countryOfOrigin
source
synonyms
averageScore
meanScore
popularity
trending
favourites
isFavourite
relations {
nodes {
id
title {
userPreferred
romaji
english
native
}
}
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
airingSchedule {
nodes {
id
airingAt
timeUntilAiring
episode
mediaId
}
}
genres
tags {
id
name
description
rank
isMediaSpoiler
isAdult
}
isAdult
}
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
airingSchedule {
nodes {
id
airingAt
timeUntilAiring
episode
mediaId
}
}
genres
tags {
id
name
description
rank
isMediaSpoiler
isAdult
}
isAdult
}
status
startedAt {
year
@@ -700,22 +756,53 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
Variables: updateBody,
}
returnedBody, _ := AniListQuery(body, true)
returnedBody, status := AniListQuery(body, true)
var badPost struct {
Errors []struct {
Message string `json:"message"`
Status int `json:"status"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
} `json:"errors"`
Data any `json:"data"`
}
if status == "403 Forbidden" {
err := json.Unmarshal(returnedBody, &badPost)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
return AniListGetSingleAnime{}, fmt.Errorf("API authentication error")
}
return AniListGetSingleAnime{}, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
}
if status != "200 OK" {
log.Printf("AniListUpdateEntry failed with status: %s, body: %s\n", status, string(returnedBody))
return AniListGetSingleAnime{}, fmt.Errorf("API request failed with status: %s", status)
}
var returnedJson AniListUpdateReturn
err := json.Unmarshal(returnedBody, &returnedJson)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
log.Printf("AniListUpdateEntry failed to unmarshal response: %s, body: %s\n", err, string(returnedBody))
return AniListGetSingleAnime{}, fmt.Errorf("failed to parse AniList update response")
}
if returnedJson.Data.SaveMediaListEntry.MediaID == 0 {
log.Printf("AniListUpdateEntry returned no saved entry, body: %s\n", string(returnedBody))
return AniListGetSingleAnime{}, fmt.Errorf("AniList returned no saved entry")
}
var post AniListGetSingleAnime
post.Data.MediaList = returnedJson.Data.SaveMediaListEntry
return post
return post, nil
}
func (a *App) AniListDeleteEntry(mediaListId int) DeleteAniListReturn {
func (a *App) AniListDeleteEntry(mediaListId int) (DeleteAniListReturn, error) {
type Variables = struct {
Id int `json:"id"`
}
@@ -740,15 +827,41 @@ func (a *App) AniListDeleteEntry(mediaListId int) DeleteAniListReturn {
},
}
returnedBody, _ := AniListQuery(body, true)
returnedBody, status := AniListQuery(body, true)
var badPost struct {
Errors []struct {
Message string `json:"message"`
Status int `json:"status"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
} `json:"errors"`
Data any `json:"data"`
}
if status == "403 Forbidden" {
err := json.Unmarshal(returnedBody, &badPost)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
return DeleteAniListReturn{}, fmt.Errorf("API authentication error")
}
return DeleteAniListReturn{}, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
}
if status != "200 OK" {
log.Printf("AniListDeleteEntry failed with status: %s, body: %s\n", status, string(returnedBody))
return DeleteAniListReturn{}, fmt.Errorf("API request failed with status: %s", status)
}
var post DeleteAniListReturn
err := json.Unmarshal(returnedBody, &post)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
return DeleteAniListReturn{}, fmt.Errorf("failed to parse AniList delete response")
}
return post
return post, nil
}
func (a *App) AniListBrowse(
+60 -57
View File
@@ -64,74 +64,43 @@ type AniListUpdateReturn struct {
}
type Media struct {
ID int `json:"id"`
IDMal int `json:"idMal"`
Title struct {
UserPreferred string `json:"userPreferred"`
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
} `json:"title"`
Description string `json:"description"`
ID int `json:"id"`
IDMal int `json:"idMal"`
Title MediaTitle `json:"title"`
Description string `json:"description"`
CoverImage struct {
ExtraLarge string
Large string `json:"large"`
Medium string
Color string
} `json:"coverImage"`
BannerImage string
Format string
Season string `json:"season"`
SeasonYear int `json:"seasonYear"`
Status string `json:"status"`
Episodes int `json:"episodes"`
Duration int
CountryOfOrigin string
Source string
Synonyms []string
AverageScore int
MeanScore int
Popularity int
Trending int
Favourites int
isFavourite bool
Relations struct {
nodes struct {
id int
Title struct {
UserPreferred string `json:"userPreferred"`
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
} `json:"title"`
}
}
StartDate struct {
Year int
Month int
Day int
}
EndDate struct {
Year int
Month int
Day int
}
BannerImage string
Format string
Season string `json:"season"`
SeasonYear int `json:"seasonYear"`
Status string `json:"status"`
Episodes int `json:"episodes"`
Duration int
CountryOfOrigin string
Source string
Synonyms []string
AverageScore int
MeanScore int
Popularity int
Trending int
Favourites int
isFavourite bool
Relations MediaRelations `json:"relations"`
StartDate MediaFuzzyDate `json:"startDate"`
EndDate MediaFuzzyDate `json:"endDate"`
NextAiringEpisode struct {
AiringAt int `json:"airingAt"`
TimeUntilAiring int `json:"timeUntilAiring"`
Episode int `json:"episode"`
} `json:"nextAiringEpisode"`
AiringSchedule struct {
Nodes struct {
Id int
AiringAt int
TimeUntilAiring int
Episode int
MediaId int
}
}
Genres []string `json:"genres"`
Tags []struct {
AiringSchedule MediaAiringSchedule `json:"airingSchedule"`
Genres []string `json:"genres"`
Tags []struct {
Id int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
@@ -142,6 +111,40 @@ type Media struct {
IsAdult bool `json:"isAdult"`
}
type MediaTitle struct {
UserPreferred string `json:"userPreferred"`
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
}
type MediaRelations struct {
Nodes []MediaRelation `json:"nodes"`
}
type MediaRelation struct {
Id int `json:"id"`
Title MediaTitle `json:"title"`
}
type MediaFuzzyDate struct {
Year int `json:"year"`
Month int `json:"month"`
Day int `json:"day"`
}
type MediaAiringSchedule struct {
Nodes []AiringScheduleNode `json:"nodes"`
}
type AiringScheduleNode struct {
Id int `json:"id"`
AiringAt int `json:"airingAt"`
TimeUntilAiring int `json:"timeUntilAiring"`
Episode int `json:"episode"`
MediaId int `json:"mediaId"`
}
type MediaList struct {
ID int `json:"id"`
MediaID int `json:"mediaId"`
+80 -16
View File
@@ -4,6 +4,7 @@
aniListLoggedIn,
malAnime,
malLoggedIn,
setApiError,
simklAnime,
simklLoggedIn,
watchlistNeedsRefresh,
@@ -207,10 +208,15 @@
completedAt: convertDateToAniList(completedAtDate),
};
await AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => {
value.data.MediaList.media.tags =
currentAniListAnime.data.MediaList.media.tags;
value.data.MediaList.media.genres =
currentAniListAnime.data.MediaList.media.genres;
if (!value.data?.MediaList || value.data.MediaList.mediaId === 0) {
setApiError(
"anilist",
"AniList update failed: no saved entry was returned",
undefined,
true,
);
return;
}
aniListAnime.update((newValue) => {
newValue = value;
return newValue;
@@ -233,7 +239,18 @@
});
});
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("Error submitting AniList changes:", error);
setApiError(
"anilist",
`Failed to sync AniList: ${errorMsg}`,
undefined,
true,
);
}
try {
if (malLoggedIn && currentMalAnime.id !== 0) {
let body: MALUploadStatus = {
status: submitData.status.mal,
@@ -286,7 +303,18 @@
},
);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("Error submitting MyAnimeList changes:", error);
setApiError(
"mal",
`Failed to sync MyAnimeList: ${errorMsg}`,
undefined,
true,
);
}
try {
if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
if (currentSimklAnime.watched_episodes_count !== submitData.episodes) {
await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
@@ -359,13 +387,19 @@
}
}
} catch (error) {
console.error("Error submitting changes:", error);
} finally {
submitting.set(false);
submitSuccess.set(true);
watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000);
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("Error submitting Simkl changes:", error);
setApiError(
"simkl",
`Failed to sync Simkl: ${errorMsg}`,
undefined,
true,
);
}
submitting.set(false);
submitSuccess.set(true);
watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000);
};
const deleteEntries = async () => {
@@ -389,6 +423,18 @@
notes: "",
});
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("Error deleting AniList entry:", error);
setApiError(
"anilist",
`Failed to delete AniList entry: ${errorMsg}`,
undefined,
true,
);
}
try {
if (malLoggedIn && currentMalAnime.id !== 0) {
await DeleteMyAnimeListEntry(currentMalAnime.id);
AddAnimeServiceToTable({
@@ -404,6 +450,18 @@
notes: "",
});
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("Error deleting MyAnimeList entry:", error);
setApiError(
"mal",
`Failed to delete MyAnimeList entry: ${errorMsg}`,
undefined,
true,
);
}
try {
if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
await SimklSyncRemove(currentSimklAnime);
AddAnimeServiceToTable({
@@ -420,13 +478,19 @@
});
}
} catch (error) {
console.error("Error deleting entries:", error);
} finally {
submitting.set(false);
submitSuccess.set(true);
watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000);
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("Error deleting Simkl entry:", error);
setApiError(
"simkl",
`Failed to delete Simkl entry: ${errorMsg}`,
undefined,
true,
);
}
submitting.set(false);
submitSuccess.set(true);
watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000);
};
let max = 999;
+147 -31
View File
@@ -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>
+161 -15
View File
@@ -1,5 +1,25 @@
export namespace main {
export class AiringScheduleNode {
id: number;
airingAt: number;
timeUntilAiring: number;
episode: number;
mediaId: number;
static createFrom(source: any = {}) {
return new AiringScheduleNode(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.airingAt = source["airingAt"];
this.timeUntilAiring = source["timeUntilAiring"];
this.episode = source["episode"];
this.mediaId = source["mediaId"];
}
}
export class AniListCurrentUserWatchList {
data: struct { Page struct { PageInfo struct { Total int "json:\"total\""; PerPage int "json:\"perPage\""; CurrentPage int "json:\"currentPage\""; LastPage int "json:\"lastPage\""; HasNextPage bool "json:\"hasNextPage\"" } "json:\"pageInfo\""; MediaList []main.;
@@ -381,11 +401,136 @@ export namespace main {
this.isAdult = source["isAdult"];
}
}
export class MediaAiringSchedule {
nodes: AiringScheduleNode[];
static createFrom(source: any = {}) {
return new MediaAiringSchedule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nodes = this.convertValues(source["nodes"], AiringScheduleNode);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class MediaFuzzyDate {
year: number;
month: number;
day: number;
static createFrom(source: any = {}) {
return new MediaFuzzyDate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.year = source["year"];
this.month = source["month"];
this.day = source["day"];
}
}
export class MediaRelation {
id: number;
title: MediaTitle;
static createFrom(source: any = {}) {
return new MediaRelation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = this.convertValues(source["title"], MediaTitle);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class MediaRelations {
nodes: MediaRelation[];
static createFrom(source: any = {}) {
return new MediaRelations(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nodes = this.convertValues(source["nodes"], MediaRelation);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class MediaTitle {
userPreferred: string;
romaji: string;
english: string;
native: string;
static createFrom(source: any = {}) {
return new MediaTitle(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.userPreferred = source["userPreferred"];
this.romaji = source["romaji"];
this.english = source["english"];
this.native = source["native"];
}
}
export class Media {
id: number;
idMal: number;
// Go type: struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" }
title: any;
title: MediaTitle;
description: string;
// Go type: struct { ExtraLarge string; Large string "json:\"large\""; Medium string; Color string }
coverImage: any;
@@ -404,16 +549,12 @@ export namespace main {
Popularity: number;
Trending: number;
Favourites: number;
// Go type: struct { nodes struct { id int; Title struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" } "json:\"title\"" } }
Relations: any;
// Go type: struct { Year int; Month int; Day int }
StartDate: any;
// Go type: struct { Year int; Month int; Day int }
EndDate: any;
relations: MediaRelations;
startDate: MediaFuzzyDate;
endDate: MediaFuzzyDate;
// Go type: struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" }
nextAiringEpisode: any;
// Go type: struct { Nodes struct { Id int; AiringAt int; TimeUntilAiring int; Episode int; MediaId int } }
AiringSchedule: any;
airingSchedule: MediaAiringSchedule;
genres: string[];
tags: [];
isAdult: boolean;
@@ -426,7 +567,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.idMal = source["idMal"];
this.title = this.convertValues(source["title"], Object);
this.title = this.convertValues(source["title"], MediaTitle);
this.description = source["description"];
this.coverImage = this.convertValues(source["coverImage"], Object);
this.BannerImage = source["BannerImage"];
@@ -444,11 +585,11 @@ export namespace main {
this.Popularity = source["Popularity"];
this.Trending = source["Trending"];
this.Favourites = source["Favourites"];
this.Relations = this.convertValues(source["Relations"], Object);
this.StartDate = this.convertValues(source["StartDate"], Object);
this.EndDate = this.convertValues(source["EndDate"], Object);
this.relations = this.convertValues(source["relations"], MediaRelations);
this.startDate = this.convertValues(source["startDate"], MediaFuzzyDate);
this.endDate = this.convertValues(source["endDate"], MediaFuzzyDate);
this.nextAiringEpisode = this.convertValues(source["nextAiringEpisode"], Object);
this.AiringSchedule = this.convertValues(source["AiringSchedule"], Object);
this.airingSchedule = this.convertValues(source["airingSchedule"], MediaAiringSchedule);
this.genres = source["genres"];
this.tags = this.convertValues(source["tags"], );
this.isAdult = source["isAdult"];
@@ -472,6 +613,8 @@ export namespace main {
return a;
}
}
export class MediaList {
id: number;
mediaId: number;
@@ -527,6 +670,9 @@ export namespace main {
return a;
}
}
export class MyAnimeListUser {
id: id;
name: name;
+1 -1
View File
@@ -12,6 +12,6 @@
},
"info": {
"productName": "AniTrack",
"productVersion": "1.5.5"
"productVersion": "1.6.5"
}
}