20 Commits

Author SHA1 Message Date
335d757255 chore: remove release notes file
Release notes will be managed externally. Keeping repository focused on source code only.
2026-03-30 20:17:09 -04:00
a3b4857175 docs: add release notes for v0.7.0
Comprehensive release notes documenting:
- Major error handling improvements
- Backend API changes (AniList, MAL, Simkl)
- Frontend error modal and state management
- Bug fixes and stability improvements
- Technical details and upgrade instructions
2026-03-30 20:12:47 -04:00
6e4b9c041a chore: remove vite timestamp file
Remove generated vite timestamp file that was accidentally tracked.
2026-03-30 20:08:39 -04:00
ae54fd20dd feat(frontend): integrate error handling into application
App.svelte:
- Import and render ErrorModal component
- Add ErrorModal to main app layout below Header

CheckIfAniListLoggedInAndLoadWatchList.svelte:
- Import error state helpers (setApiError, clearApiError)
- Wrap LoadAniListUser in try-catch with error handling
- Wrap LoadAniListWatchList in try-catch with error handling
- Update CheckIfAniListLoggedInAndLoadWatchList with error handling
- Remove old alert() calls in favor of modal system

Home.svelte:
- Import isApiDown and apiError stores
- Add conditional rendering for API down state
- Display user-friendly "API Unavailable" message when apiError is set
- Show warning icon and helpful messaging

Error handling is now fully integrated across the frontend application.
2026-03-30 20:08:34 -04:00
2c7e2d0eff feat(frontend): add error modal component
Add new ErrorModal component for API error display:
- Auto-displays when apiError store is set
- Shows service name, error message, and status code
- Provides "Retry Connection" button to attempt reconnection
- Provides "Dismiss" button to close modal and continue
- Integrates with all three services (AniList, MAL, Simkl)
- Uses flowbite-svelte Modal and Button components
- Proper Svelte event handling with on:click

Uses Tailwind CSS for styling with red error theme and helpful messaging.
2026-03-30 20:08:29 -04:00
9a9f055c38 feat(frontend): add API error state management
Add centralized error state system:
- New ApiError interface (service, message, statusCode, canRetry)
- apiError writable store for current error state
- isApiDown writable store for API availability status
- setApiError() helper to set error states
- clearApiError() helper to reset error states

Provides reactive error state across entire application.
2026-03-30 20:08:23 -04:00
bc497521e7 feat(simkl): add comprehensive error handling
SimklFunctions.go:
- Update SimklHelper to return (json.RawMessage, error)
- Add status code validation (200-299 range)
- Update SimklGetUserWatchlist to return (SimklWatchListType, error)
- Update SimklSyncEpisodes to return (SimklAnime, error)
- Update SimklSyncRating to return (SimklAnime, error)
- Update SimklSyncStatus to return (SimklAnime, error)
- Update SimklSearch to return (SimklAnime, error)
- Update SimklSyncRemove to return (bool, error)
- Add proper error wrapping for all failure scenarios

SimklUserFunctions.go:
- Replace log.Fatalf with log.Printf in server error handling (line 119)
- Replace log.Fatal with log.Printf in JSON marshaling (line 141)

All Simkl API calls now properly propagate errors to frontend.
2026-03-30 20:08:20 -04:00
48a6005725 feat(mal): add comprehensive error handling
MALFunctions.go:
- Update MALHelper to return (json.RawMessage, string, error)
- Add network error handling and proper request error checking
- Update GetMyAnimeList to return (MALWatchlist, error)
- Update MyAnimeListUpdate to return (MalListStatus, error)
- Update GetMyAnimeListAnime to return (MALAnime, error)
- Update DeleteMyAnimeListEntry to return (bool, error)

MALUserFunctions.go:
- Replace log.Fatalf with log.Printf in server error handling
- Prevent server shutdown on OAuth callback errors

All MAL API calls now properly propagate errors to frontend.
2026-03-30 20:08:16 -04:00
93a94d0797 chore: bump version to 0.7.0
Preparing release for comprehensive error handling improvements across all API integrations (AniList, MAL, Simkl).
2026-03-30 20:08:08 -04:00
a2576b044c feat: implement dynamic sort parameter for AniList watchlist
Add configurable sort functionality to AniList watchlist system:

- Add aniListSort writable store to GlobalVariablesAndHelperFunctions
- Update Pagination component to subscribe to and use dynamic sort parameter
- Refactor CheckIfAniListLoggedInAndLoadWatchList to use sort from store
- Remove hardcoded MediaListSort.UpdatedTimeDesc in favor of configurable sort
- Improve code formatting with arrow functions and consistent spacing
- Add sort parameter to all GetAniListUserWatchingList calls

This allows users to customize their watchlist sorting preference instead of being limited to the default 'updated time descending' sort order.
2026-03-29 10:23:58 -04:00
2ee2d85e9e feat: add Sort component placeholder
Add new Sort component to infrastructure for future sort functionality implementation in the watchlist UI.
2026-03-29 10:23:55 -04:00
1090f112f3 refactor: standardize code formatting in AvatarMenu component
- Convert from 4-space to 2-space indentation for consistency
- Improve import statement formatting and alignment
- Fix semicolon usage throughout the component
- Add conditional avatar dot indicator: green when all services (AniList, MAL, Simkl) are logged in, yellow otherwise
- Add missing newline at end of file
- General code style improvements for better readability and consistency with project standards
2026-03-22 22:00:48 -04:00
58c9f449e0 chore: bump version to 0.6.5 2026-03-22 21:46:05 -04:00
f016c90353 chore: bump version to 0.6.5
Update product version in preparation for release.
2026-03-22 21:45:57 -04:00
6bbe0f0f48 Add disabled state to pagination navigation buttons
- Disable decrement button when on first page (page <= 1)
- Disable increment button when on last page (page >= lastPage)
- Prevents users from triggering invalid page navigation requests
- Improves UX by providing visual feedback for boundary conditions

This change prevents unnecessary API calls and improves user experience by clearly indicating when navigation bounds have been reached. The buttons will now be disabled at the appropriate boundaries, matching the behavior already present in the numbered page navigation section.
2026-03-22 21:17:20 -04:00
d841fee1e7 Fix TypeScript type safety in Pagination event handlers
- Add proper type annotations to changePage function parameter using KeyboardEvent with HTMLInputElement currentTarget
- Add proper type annotations to changeCountPerPage function parameter using Event with HTMLSelectElement currentTarget
- Replace all e.target references with e.currentTarget to access properly typed DOM elements
- Add hover state styling to active page button for better UI feedback

This change resolves TypeScript errors where EventTarget type didn't have access to element-specific properties like 'value'. Using currentTarget instead of target provides the correct type since currentTarget refers to the element that has the event listener attached, ensuring type-safe access to input and select element properties.
2026-03-22 21:09:13 -04:00
f29d8f378e Format Pagination component code for consistency
- Update indentation from 4-space to 2-space convention throughout the component
- Reformat import statements to follow consistent spacing and alignment
- Standardize function and variable declarations with proper spacing
- Improve code readability with consistent formatting across all elements
- Clean up HTML template structure with proper indentation
- Add newline at end of file for standards compliance

This change ensures the Pagination component follows the project's code style conventions and improves maintainability through consistent formatting.
2026-03-22 20:50:24 -04:00
35e93c0ca9 Fix media cover image sizing in WatchList component
- Add explicit width (230px) and height (330px) to media cover images
- Apply object-cover class to maintain aspect ratio and prevent distortion
- Ensures uniform sizing across all media cover images in the watch list

This change improves visual consistency in the UI by standardizing the display dimensions of anime/manga cover images.
2026-03-22 20:48:56 -04:00
8c169d549a Add disabled state constraints to progress adjustment buttons in Anime component
- Disable decrement button when progress is at 0 or below to prevent negative values
- Disable increment button when:
  * Media has defined episodes and progress is complete (>= total episodes)
  * Or when progress has reached next airing episode boundary (nextAiringEpisode - 2)
- Improves user experience by preventing invalid progress adjustments
- Maintains data integrity by stopping users from setting impossible progress values
2026-03-22 20:27:15 -04:00
b2a8a504f3 fix: resolve syntax error in App.svelte
Fixed TypeScript compilation error caused by import statement and function declaration being on the same line.

Changes:
- Separated import statement and onMount declaration onto different lines
- Resolved svelte-preprocess type error
- File now compiles correctly

This was a typo from previous commit where the loc import line was incorrectly merged with the existing onMount function declaration.
2026-03-21 13:28:33 -04:00
17 changed files with 792 additions and 429 deletions

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -183,7 +184,7 @@ func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
return post return post
} }
func (a *App) AniListSearch(query string) any { func (a *App) AniListSearch(query string) (interface{}, error) {
type Variables struct { type Variables struct {
Search string `json:"search"` Search string `json:"search"`
ListType string `json:"listType"` ListType string `json:"listType"`
@@ -242,18 +243,20 @@ func (a *App) AniListSearch(query string) any {
ListType: "ANIME", ListType: "ANIME",
}, },
} }
returnedBody, _ := AniListQuery(body, false) returnedBody, status := AniListQuery(body, false)
if status != "200 OK" {
return nil, fmt.Errorf("API search failed with status: %s", status)
}
var post interface{} var post interface{}
err := json.Unmarshal(returnedBody, &post) err := json.Unmarshal(returnedBody, &post)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return nil, fmt.Errorf("Failed to parse search results")
} }
return post, nil
return post
} }
func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) AniListCurrentUserWatchList { func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) (AniListCurrentUserWatchList, error) {
user := a.GetAniListLoggedInUser() user := a.GetAniListLoggedInUser()
type Variables struct { type Variables struct {
Page int `json:"page"` Page int `json:"page"`
@@ -404,11 +407,15 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
err := json.Unmarshal(returnedBody, &badPost) err := json.Unmarshal(returnedBody, &badPost)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return post, fmt.Errorf("API authentication error")
} }
log.Fatal(badPost.Errors[0].Message) return post, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
}
if status != "200 OK" {
return post, fmt.Errorf("API request failed with status: %s", status)
} }
return post return post, nil
} }
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime { func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime {

View File

@@ -11,10 +11,19 @@ import (
"strings" "strings"
) )
func MALHelper(method string, malUrl string, body url.Values) (json.RawMessage, string) { func MALHelper(method string, malUrl string, body url.Values) (json.RawMessage, string, error) {
client := &http.Client{} client := &http.Client{}
req, _ := http.NewRequest(method, malUrl, strings.NewReader(body.Encode())) req, err := http.NewRequest(method, malUrl, strings.NewReader(body.Encode()))
if err != nil {
message, _ := json.Marshal(struct {
Message string `json:"message"`
}{
Message: "Failed to create request: " + err.Error(),
})
return message, "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Authorization", "Bearer "+myAnimeListJwt.AccessToken) req.Header.Add("Authorization", "Bearer "+myAnimeListJwt.AccessToken)
@@ -22,47 +31,57 @@ func MALHelper(method string, malUrl string, body url.Values) (json.RawMessage,
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
fmt.Println("Errored when sending request to the server")
message, _ := json.Marshal(struct { message, _ := json.Marshal(struct {
Message string `json:"message" ts_type:"message"` Message string `json:"message"`
}{ }{
Message: "Errored when sending request to the server" + err.Error(), Message: "Network error: " + err.Error(),
}) })
return message, "", fmt.Errorf("network error: %w", err)
return message, resp.Status
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.Status, fmt.Errorf("failed to read response: %w", err)
}
if resp.Status == "401 Unauthorized" { if resp.Status == "401 Unauthorized" {
refreshMyAnimeListAuthorizationToken() refreshMyAnimeListAuthorizationToken()
MALHelper(method, malUrl, body) return MALHelper(method, malUrl, body)
} }
return respBody, resp.Status if resp.Status != "200 OK" && resp.Status != "201 Created" && resp.Status != "202 Accepted" {
return respBody, resp.Status, fmt.Errorf("API returned status: %s", resp.Status)
}
return respBody, resp.Status, nil
} }
func (a *App) GetMyAnimeList(count int) MALWatchlist { func (a *App) GetMyAnimeList(count int) (MALWatchlist, error) {
limit := strconv.Itoa(count) limit := strconv.Itoa(count)
user := a.GetMyAnimeListLoggedInUser() user := a.GetMyAnimeListLoggedInUser()
malUrl := "https://api.myanimelist.net/v2/users/" + user.Name + "/animelist?fields=list_status&status=watching&limit=" + limit malUrl := "https://api.myanimelist.net/v2/users/" + user.Name + "/animelist?fields=list_status&status=watching&limit=" + limit
var malList MALWatchlist var malList MALWatchlist
respBody, resStatus := MALHelper("GET", malUrl, nil) respBody, resStatus, err := MALHelper("GET", malUrl, nil)
if err != nil {
return malList, fmt.Errorf("failed to get MAL watchlist: %w", err)
}
if resStatus == "200 OK" { if resStatus == "200 OK" {
err := json.Unmarshal(respBody, &malList) err := json.Unmarshal(respBody, &malList)
if err != nil { if err != nil {
log.Printf("Failed to unmarshal json response, %s\n", err) log.Printf("Failed to unmarshal json response, %s\n", err)
return malList, fmt.Errorf("failed to parse response: %w", err)
} }
} }
return malList return malList, nil
} }
func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) MalListStatus { func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) (MalListStatus, error) {
if update.NumTimesRewatched >= 1 { if update.NumTimesRewatched >= 1 {
update.IsRewatching = true update.IsRewatching = true
} else { } else {
@@ -78,39 +97,52 @@ func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) MalListS
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(anime.Id) + "/my_list_status" malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(anime.Id) + "/my_list_status"
var status MalListStatus var status MalListStatus
respBody, respStatus := MALHelper("PATCH", malUrl, body) respBody, respStatus, err := MALHelper("PATCH", malUrl, body)
if err != nil {
return status, fmt.Errorf("failed to update MAL entry: %w", err)
}
if respStatus == "200 OK" { if respStatus == "200 OK" {
err := json.Unmarshal(respBody, &status) err := json.Unmarshal(respBody, &status)
if err != nil { if err != nil {
log.Printf("Failed to unmarshal json response, %s\n", err) log.Printf("Failed to unmarshal json response, %s\n", err)
return status, fmt.Errorf("failed to parse response: %w", err)
} }
} }
return status, nil
return status
} }
func (a *App) GetMyAnimeListAnime(id int) MALAnime { func (a *App) GetMyAnimeListAnime(id int) (MALAnime, error) {
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,genres,created_at,updated_at,media_type,status,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,recommendations,studios,statistics" malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,genres,created_at,updated_at,media_type,status,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,recommendations,studios,statistics"
respBody, respStatus := MALHelper("GET", malUrl, nil)
var malAnime MALAnime var malAnime MALAnime
respBody, respStatus, err := MALHelper("GET", malUrl, nil)
if err != nil {
return malAnime, fmt.Errorf("failed to get MAL anime: %w", err)
}
if respStatus == "200 OK" { if respStatus == "200 OK" {
err := json.Unmarshal(respBody, &malAnime) err := json.Unmarshal(respBody, &malAnime)
if err != nil { if err != nil {
log.Printf("Failed to unmarshal json response, %s\n", err) log.Printf("Failed to unmarshal json response, %s\n", err)
return malAnime, fmt.Errorf("failed to parse response: %w", err)
} }
} }
return malAnime, nil
return malAnime
} }
func (a *App) DeleteMyAnimeListEntry(id int) bool { func (a *App) DeleteMyAnimeListEntry(id int) (bool, error) {
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "/my_list_status" malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "/my_list_status"
_, respStatus := MALHelper("DELETE", malUrl, nil) _, respStatus, err := MALHelper("DELETE", malUrl, nil)
if err != nil {
return false, fmt.Errorf("failed to delete MAL entry: %w", err)
}
if respStatus == "200 OK" { if respStatus == "200 OK" {
return true return true, nil
} else { } else {
return false return false, fmt.Errorf("delete failed with status: %s", respStatus)
} }
} }

View File

@@ -176,7 +176,7 @@ func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifi
go func() { go func() {
defer wg.Done() defer wg.Done()
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %s\n", err) log.Printf("Server error: %s\n", err)
} }
fmt.Println("Shutting down...") fmt.Println("Shutting down...")
}() }()

View File

@@ -14,16 +14,24 @@ import (
var SimklWatchList SimklWatchListType var SimklWatchList SimklWatchListType
func SimklHelper(method string, url string, body interface{}) json.RawMessage { func SimklHelper(method string, url string, body interface{}) (json.RawMessage, error) {
reader, _ := json.Marshal(body) reader, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal body: %w", err)
}
var req *http.Request var req *http.Request
client := &http.Client{} client := &http.Client{}
if body != nil { if body != nil {
req, _ = http.NewRequest(method, url, bytes.NewBuffer(reader)) req, err = http.NewRequest(method, url, bytes.NewBuffer(reader))
} else { } else {
req, _ = http.NewRequest(method, url, nil) req, err = http.NewRequest(method, url, nil)
}
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
@@ -32,41 +40,45 @@ func SimklHelper(method string, url string, body interface{}) json.RawMessage {
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
fmt.Println("Errored when sending request to the server") return nil, fmt.Errorf("network error: %w", err)
message, _ := json.Marshal(struct {
Message string `json:"message"`
}{
Message: "Errored when sending request to the server" + err.Error(),
})
return message
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
return respBody if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return respBody, fmt.Errorf("API returned status: %d", resp.StatusCode)
}
return respBody, nil
} }
func (a *App) SimklGetUserWatchlist() SimklWatchListType { func (a *App) SimklGetUserWatchlist() (SimklWatchListType, error) {
method := "GET" method := "GET"
url := "https://api.simkl.com/sync/all-items/anime" url := "https://api.simkl.com/sync/all-items/anime"
respBody := SimklHelper(method, url, nil) respBody, err := SimklHelper(method, url, nil)
if err != nil {
return SimklWatchListType{}, fmt.Errorf("failed to get Simkl watchlist: %w", err)
}
var errCheck struct { var errCheck struct {
Error string `json:"error"` Error string `json:"error"`
Message string `json:"message"` Message string `json:"message"`
} }
err := json.Unmarshal(respBody, &errCheck) err = json.Unmarshal(respBody, &errCheck)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return SimklWatchListType{}, fmt.Errorf("failed to parse error response: %w", err)
} }
if errCheck.Error != "" { if errCheck.Error != "" {
a.LogoutSimkl() a.LogoutSimkl()
return SimklWatchListType{} return SimklWatchListType{}, fmt.Errorf("Simkl API error: %s", errCheck.Message)
} }
var watchlist SimklWatchListType var watchlist SimklWatchListType
@@ -74,14 +86,15 @@ func (a *App) SimklGetUserWatchlist() SimklWatchListType {
err = json.Unmarshal(respBody, &watchlist) err = json.Unmarshal(respBody, &watchlist)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return SimklWatchListType{}, fmt.Errorf("failed to parse watchlist: %w", err)
} }
SimklWatchList = watchlist SimklWatchList = watchlist
return watchlist return watchlist, nil
} }
func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) SimklAnime { func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) (SimklAnime, error) {
var episodes []Episode var episodes []Episode
var url string var url string
var shows []SimklPostShow var shows []SimklPostShow
@@ -112,13 +125,19 @@ func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) SimklAnime {
simklSync := SimklSyncHistoryType{shows} simklSync := SimklSyncHistoryType{shows}
respBody := SimklHelper("POST", url, simklSync) respBody, err := SimklHelper("POST", url, simklSync)
if err != nil {
log.Printf("Failed to sync episodes: %s\n", err)
return anime, fmt.Errorf("failed to sync episodes: %w", err)
}
var success interface{} var success interface{}
err := json.Unmarshal(respBody, &success) err = json.Unmarshal(respBody, &success)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return anime, fmt.Errorf("failed to parse response: %w", err)
} }
for i, simklAnime := range SimklWatchList.Anime { for i, simklAnime := range SimklWatchList.Anime {
@@ -131,10 +150,10 @@ func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) SimklAnime {
WatchListUpdate(anime) WatchListUpdate(anime)
return anime return anime, nil
} }
func (a *App) SimklSyncRating(anime SimklAnime, rating int) SimklAnime { func (a *App) SimklSyncRating(anime SimklAnime, rating int) (SimklAnime, error) {
var url string var url string
showWithRating := ShowWithRating{ showWithRating := ShowWithRating{
Title: anime.Show.Title, Title: anime.Show.Title,
@@ -169,13 +188,17 @@ func (a *App) SimklSyncRating(anime SimklAnime, rating int) SimklAnime {
Shows []interface{} `json:"shows" ts_type:"shows"` Shows []interface{} `json:"shows" ts_type:"shows"`
}{shows} }{shows}
respBody := SimklHelper("POST", url, simklSync) respBody, err := SimklHelper("POST", url, simklSync)
if err != nil {
log.Printf("Failed to sync rating: %s\n", err)
return anime, fmt.Errorf("failed to sync rating: %w", err)
}
var success interface{} var success interface{}
err := json.Unmarshal(respBody, &success) err = json.Unmarshal(respBody, &success)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return anime, fmt.Errorf("failed to parse response: %w", err)
} }
for i, simklAnime := range SimklWatchList.Anime { for i, simklAnime := range SimklWatchList.Anime {
@@ -188,10 +211,10 @@ func (a *App) SimklSyncRating(anime SimklAnime, rating int) SimklAnime {
WatchListUpdate(anime) WatchListUpdate(anime)
return anime return anime, nil
} }
func (a *App) SimklSyncStatus(anime SimklAnime, status string) SimklAnime { func (a *App) SimklSyncStatus(anime SimklAnime, status string) (SimklAnime, error) {
url := "https://api.simkl.com/sync/add-to-list" url := "https://api.simkl.com/sync/add-to-list"
show := SimklShowStatus{ show := SimklShowStatus{
Title: anime.Show.Title, Title: anime.Show.Title,
@@ -211,13 +234,17 @@ func (a *App) SimklSyncStatus(anime SimklAnime, status string) SimklAnime {
Shows []SimklShowStatus `json:"shows" ts_type:"shows"` Shows []SimklShowStatus `json:"shows" ts_type:"shows"`
}{shows} }{shows}
respBody := SimklHelper("POST", url, simklSync) respBody, err := SimklHelper("POST", url, simklSync)
if err != nil {
log.Printf("Failed to sync status: %s\n", err)
return anime, fmt.Errorf("failed to sync status: %w", err)
}
var success interface{} var success interface{}
err := json.Unmarshal(respBody, &success) err = json.Unmarshal(respBody, &success)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return anime, fmt.Errorf("failed to parse response: %w", err)
} }
for i, simklAnime := range SimklWatchList.Anime { for i, simklAnime := range SimklWatchList.Anime {
@@ -230,15 +257,20 @@ func (a *App) SimklSyncStatus(anime SimklAnime, status string) SimklAnime {
WatchListUpdate(anime) WatchListUpdate(anime)
return anime return anime, nil
} }
func (a *App) SimklSearch(aniListAnime MediaList) SimklAnime { func (a *App) SimklSearch(aniListAnime MediaList) (SimklAnime, error) {
var result SimklAnime var result SimklAnime
if reflect.DeepEqual(SimklWatchList, SimklWatchListType{}) { if reflect.DeepEqual(SimklWatchList, SimklWatchListType{}) {
fmt.Println("Watchlist empty. Calling...") fmt.Println("Watchlist empty. Calling...")
SimklWatchList = a.SimklGetUserWatchlist() watchlist, err := a.SimklGetUserWatchlist()
if err != nil {
log.Printf("Failed to get watchlist: %s\n", err)
return result, fmt.Errorf("failed to load watchlist for search: %w", err)
}
SimklWatchList = watchlist
} }
for _, anime := range SimklWatchList.Anime { for _, anime := range SimklWatchList.Anime {
@@ -255,22 +287,30 @@ func (a *App) SimklSearch(aniListAnime MediaList) SimklAnime {
var anime SimklSearchType var anime SimklSearchType
url := "https://api.simkl.com/search/id?anilist=" + strconv.Itoa(aniListAnime.Media.ID) url := "https://api.simkl.com/search/id?anilist=" + strconv.Itoa(aniListAnime.Media.ID)
respBody := SimklHelper("GET", url, nil) respBody, err := SimklHelper("GET", url, nil)
if err != nil {
err := json.Unmarshal(respBody, &anime) log.Printf("Failed to search Simkl: %s\n", err)
return result, fmt.Errorf("failed to search Simkl by AniList ID: %w", err)
}
err = json.Unmarshal(respBody, &anime)
if len(anime) == 0 { if len(anime) == 0 {
url = "https://api.simkl.com/search/id?mal=" + strconv.Itoa(aniListAnime.Media.IDMal) url = "https://api.simkl.com/search/id?mal=" + strconv.Itoa(aniListAnime.Media.IDMal)
respBody = SimklHelper("GET", url, nil) respBody, err = SimklHelper("GET", url, nil)
if err != nil {
log.Printf("Failed to search Simkl by MAL ID: %s\n", err)
return result, fmt.Errorf("failed to search by MAL ID: %w", err)
}
err = json.Unmarshal(respBody, &anime) err = json.Unmarshal(respBody, &anime)
} }
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return result, fmt.Errorf("failed to parse search results: %w", err)
} }
if len(anime) == 0 { if len(anime) == 0 {
return result return result, nil
} }
for _, watchListAnime := range SimklWatchList.Anime { for _, watchListAnime := range SimklWatchList.Anime {
@@ -288,10 +328,10 @@ func (a *App) SimklSearch(aniListAnime MediaList) SimklAnime {
} }
} }
return result return result, nil
} }
func (a *App) SimklSyncRemove(anime SimklAnime) bool { func (a *App) SimklSyncRemove(anime SimklAnime) (bool, error) {
url := "https://api.simkl.com/sync/history/remove" url := "https://api.simkl.com/sync/history/remove"
var showArray []SimklShowStatus var showArray []SimklShowStatus
@@ -312,25 +352,28 @@ func (a *App) SimklSyncRemove(anime SimklAnime) bool {
Shows: showArray, Shows: showArray,
} }
respBody := SimklHelper("POST", url, show) respBody, err := SimklHelper("POST", url, show)
if err != nil {
log.Printf("Failed to sync remove: %s\n", err)
return false, fmt.Errorf("failed to sync remove: %w", err)
}
var success SimklDeleteType var success SimklDeleteType
err = json.Unmarshal(respBody, &success)
err := json.Unmarshal(respBody, &success)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return false, fmt.Errorf("failed to parse response: %w", err)
} }
if success.Deleted.Shows >= 1 { if success.Deleted.Shows >= 1 {
for i, simklAnime := range SimklWatchList.Anime { for i, simklAnime := range SimklWatchList.Anime {
if simklAnime.Show.Ids.Simkl == anime.Show.Ids.Simkl { if simklAnime.Show.Ids.Simkl == anime.Show.Ids.Simkl {
SimklWatchList.Anime = slices.Delete(SimklWatchList.Anime, i, i+1) SimklWatchList.Anime = slices.Delete(SimklWatchList.Anime, i, i+1)
} }
} }
return true return true, nil
} else {
return false
} }
return false, fmt.Errorf("no shows were deleted")
} }
func WatchListUpdate(anime SimklAnime) { func WatchListUpdate(anime SimklAnime) {

View File

@@ -116,7 +116,7 @@ func (a *App) handleSimklCallback(wg *sync.WaitGroup) {
go func() { go func() {
defer wg.Done() defer wg.Done()
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %s\n", err) log.Printf("Server error: %s\n", err)
} }
fmt.Println("Shutting down...") fmt.Println("Shutting down...")
}() }()
@@ -138,7 +138,8 @@ func getSimklAuthorizationToken(content string) SimklJWT {
} }
jsonData, err := json.Marshal(data) jsonData, err := json.Marshal(data)
if err != nil { if err != nil {
log.Fatal(err) log.Printf("Failed to marshal data: %s\n", err)
return SimklJWT{}
} }
response, err := http.NewRequest("POST", "https://api.simkl.com/oauth/token", bytes.NewBuffer(jsonData)) response, err := http.NewRequest("POST", "https://api.simkl.com/oauth/token", bytes.NewBuffer(jsonData))

View File

@@ -24,7 +24,9 @@
GetMyAnimeList, GetMyAnimeList,
SimklGetUserWatchlist, SimklGetUserWatchlist,
} from "../wailsjs/go/main/App"; } from "../wailsjs/go/main/App";
import { loc } from "svelte-spa-router"; onMount(async () => { import { loc } from "svelte-spa-router";
import ErrorModal from "./helperComponents/ErrorModal.svelte";
onMount(async () => { onMount(async () => {
let isAniListLoggedIn: boolean; let isAniListLoggedIn: boolean;
let isMALLoggedIn: boolean; let isMALLoggedIn: boolean;
@@ -56,6 +58,7 @@
</script> </script>
<Header /> <Header />
<ErrorModal />
<Router <Router
routes={{ routes={{
"/": Home, "/": Home,

View File

@@ -483,7 +483,10 @@
completedAtDate = null; completedAtDate = null;
} }
}} }}
class="bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none" disabled={currentAniListAnime.data.MediaList.progress <= 0}
class={currentAniListAnime.data.MediaList.progress <= 0
? "border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
> >
<svg <svg
class="w-3 h-3 text-white" class="w-3 h-3 text-white"
@@ -541,7 +544,27 @@
if (startedAtDate === null) startedAtDate = new Date(); if (startedAtDate === null) startedAtDate = new Date();
} }
}} }}
class="bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none" disabled={(currentAniListAnime.data.MediaList.media.episodes >
0 &&
currentAniListAnime.data.MediaList.progress >=
currentAniListAnime.data.MediaList.media.episodes) ||
(currentAniListAnime.data.MediaList.media.nextAiringEpisode
.episode > 0 &&
currentAniListAnime.data.MediaList.progress >
currentAniListAnime.data.MediaList.media.nextAiringEpisode
.episode -
2)}
class={(currentAniListAnime.data.MediaList.media.episodes > 0 &&
currentAniListAnime.data.MediaList.progress >=
currentAniListAnime.data.MediaList.media.episodes) ||
(currentAniListAnime.data.MediaList.media.nextAiringEpisode
.episode > 0 &&
currentAniListAnime.data.MediaList.progress >
currentAniListAnime.data.MediaList.media.nextAiringEpisode
.episode -
2)
? "border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
> >
<svg <svg
class="w-3 h-3 text-white" class="w-3 h-3 text-white"

View File

@@ -1,163 +1,177 @@
<script lang="ts"> <script lang="ts">
import { Avatar } from "flowbite-svelte"; import { Avatar } from "flowbite-svelte";
import type { AniListUser } from "../anilist/types/AniListTypes"; import type { AniListUser } from "../anilist/types/AniListTypes";
import { import {
aniListLoggedIn, aniListLoggedIn,
aniListUser, aniListUser,
malUser, malUser,
simklUser, simklUser,
malLoggedIn, malLoggedIn,
simklLoggedIn, simklLoggedIn,
loginToAniList, loginToAniList,
loginToMAL, loginToMAL,
loginToSimkl, loginToSimkl,
logoutOfAniList, logoutOfAniList,
logoutOfMAL, logoutOfMAL,
logoutOfSimkl logoutOfSimkl,
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte"; } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
import * as runtime from "../../wailsjs/runtime"; import * as runtime from "../../wailsjs/runtime";
import type {MyAnimeListUser} from "../mal/types/MALTypes"; import type { MyAnimeListUser } from "../mal/types/MALTypes";
import type {SimklUser} from "../simkl/types/simklTypes"; import type { SimklUser } from "../simkl/types/simklTypes";
import { ShowVersion } from "../../wailsjs/go/main/App"; import { ShowVersion } from "../../wailsjs/go/main/App";
let currentAniListUser: AniListUser; let currentAniListUser: AniListUser;
let currentMALUser: MyAnimeListUser; let currentMALUser: MyAnimeListUser;
let currentSimklUser: SimklUser; let currentSimklUser: SimklUser;
let isAniListLoggedIn: boolean; let isAniListLoggedIn: boolean;
let isSimklLoggedIn: boolean; let isSimklLoggedIn: boolean;
let isMALLoggedIn: boolean; let isMALLoggedIn: boolean;
aniListUser.subscribe((value) => (currentAniListUser = value)); aniListUser.subscribe((value) => (currentAniListUser = value));
malUser.subscribe((value) => (currentMALUser = value)) malUser.subscribe((value) => (currentMALUser = value));
simklUser.subscribe(value => currentSimklUser = value) simklUser.subscribe((value) => (currentSimklUser = value));
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value)); aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value)); simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
malLoggedIn.subscribe((value) => (isMALLoggedIn = value)); malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
function dropdownUser(): void { function dropdownUser(): void {
let dropdown = document.querySelector("#userDropdown"); let dropdown = document.querySelector("#userDropdown");
dropdown.classList.toggle("hidden"); dropdown.classList.toggle("hidden");
if (!dropdown.classList.contains("hidden")) { if (!dropdown.classList.contains("hidden")) {
document.addEventListener("click", clickOutside) document.addEventListener("click", clickOutside);
}
} }
}
function clickOutside(event: Event): void { function clickOutside(event: Event): void {
let dropdown = document.querySelector("#userDropdown") let dropdown = document.querySelector("#userDropdown");
let toggleBtn = document.querySelector("#userDropdownButton") let toggleBtn = document.querySelector("#userDropdownButton");
if (!dropdown.contains(event.target as Node) && !toggleBtn.contains(event.target as Node)) { if (
dropdown.classList.add("hidden") !dropdown.contains(event.target as Node) &&
document.removeEventListener("click", clickOutside) !toggleBtn.contains(event.target as Node)
} ) {
dropdown.classList.add("hidden");
document.removeEventListener("click", clickOutside);
} }
}
</script> </script>
<div class="relative"> <div class="relative">
<button id="userDropdownButton" on:click={dropdownUser}> <button id="userDropdownButton" on:click={dropdownUser}>
{#if isAniListLoggedIn} {#if isAniListLoggedIn}
<Avatar <Avatar
src={currentAniListUser.data.Viewer.avatar.medium} src={currentAniListUser.data.Viewer.avatar.medium}
class="cursor-pointer" class="cursor-pointer"
dot={{ color: "green" }} dot={isAniListLoggedIn && isMALLoggedIn && isSimklLoggedIn
/> ? { color: "green" }
{:else} : { color: "yellow" }}
<Avatar class="cursor-pointer" dot={{ color: "red" }} /> />
{/if} {:else}
</button> <Avatar class="cursor-pointer" dot={{ color: "red" }} />
<div {/if}
id="userDropdown" </button>
class="absolute hidden right-0 2xl:left-1/2 2xl:-translate-x-1/2 z-10 divide-y rounded-lg shadow w-44 bg-gray-700 divide-gray-600" <div
> id="userDropdown"
<div class="px-4 py-3 text-sm text-white"> class="absolute hidden right-0 2xl:left-1/2 2xl:-translate-x-1/2 z-10 divide-y rounded-lg shadow w-44 bg-gray-700 divide-gray-600"
{#if isAniListLoggedIn} >
<div>{currentAniListUser.data.Viewer.name}</div> <div class="px-4 py-3 text-sm text-white">
{:else} {#if isAniListLoggedIn}
<div>You are not logged into AniList</div> <div>{currentAniListUser.data.Viewer.name}</div>
{/if} {:else}
</div> <div>You are not logged into AniList</div>
<ul {/if}
class="py-2 text-sm text-gray-200"
aria-labelledby="dropdownUserAvatarButton"
>
{#if isAniListLoggedIn}
<li>
<button
on:click={logoutOfAniList}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-green-800 hover:text-white"
>
<span class="maple-font text-lg text-green-200 mr-4">A</span>Logout {currentAniListUser.data.Viewer.name}
</button>
</li>
{:else}
<li>
<button on:click={() => {
dropdownUser()
loginToAniList()
}}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white">
<span class="maple-font text-lg mr-4">A</span>Login to AniList
</button>
</li>
{/if}
{#if isMALLoggedIn}
<li>
<button
on:click={logoutOfMAL}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-blue-800 hover:text-white"
>
<span class="maple-font text-lg text-blue-200 mr-4">M</span>Logout {currentMALUser.name}
</button>
</li>
{:else}
<li>
<button on:click={() => {
dropdownUser()
loginToMAL()
}}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white">
<span class="maple-font text-lg mr-4">M</span>Login to MyAnimeList
</button>
</li>
{/if}
{#if isSimklLoggedIn}
<li>
<button
on:click={logoutOfSimkl}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-indigo-800 hover:text-white"
>
<span class="maple-font text-lg text-indigo-200 mr-4">S</span>Logout {currentSimklUser.user.name}
</button>
</li>
{:else}
<li>
<button on:click={() => {
dropdownUser()
loginToSimkl()
}}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white">
<span class="maple-font text-lg mr-4">S</span>Login to Simkl
</button>
</li>
{/if}
</ul>
<div class="py-2">
<button
on:click={() => {
dropdownUser()
ShowVersion()
}}
class="block px-4 py-2 w-full text-sm hover:bg-gray-600 text-gray-200 over:text-white"
>
Version
</button>
<button
on:click={() => runtime.Quit()}
class="block px-4 py-2 w-full text-sm hover:bg-gray-600 text-gray-200 over:text-white"
>
Exit Application
</button>
</div>
</div> </div>
</div> <ul
class="py-2 text-sm text-gray-200"
aria-labelledby="dropdownUserAvatarButton"
>
{#if isAniListLoggedIn}
<li>
<button
on:click={logoutOfAniList}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-green-800 hover:text-white"
>
<span class="maple-font text-lg text-green-200 mr-4">A</span>Logout {currentAniListUser
.data.Viewer.name}
</button>
</li>
{:else}
<li>
<button
on:click={() => {
dropdownUser();
loginToAniList();
}}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white"
>
<span class="maple-font text-lg mr-4">A</span>Login to AniList
</button>
</li>
{/if}
{#if isMALLoggedIn}
<li>
<button
on:click={logoutOfMAL}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-blue-800 hover:text-white"
>
<span class="maple-font text-lg text-blue-200 mr-4">M</span>Logout {currentMALUser.name}
</button>
</li>
{:else}
<li>
<button
on:click={() => {
dropdownUser();
loginToMAL();
}}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white"
>
<span class="maple-font text-lg mr-4">M</span>Login to MyAnimeList
</button>
</li>
{/if}
{#if isSimklLoggedIn}
<li>
<button
on:click={logoutOfSimkl}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-indigo-800 hover:text-white"
>
<span class="maple-font text-lg text-indigo-200 mr-4">S</span>Logout {currentSimklUser
.user.name}
</button>
</li>
{:else}
<li>
<button
on:click={() => {
dropdownUser();
loginToSimkl();
}}
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white"
>
<span class="maple-font text-lg mr-4">S</span>Login to Simkl
</button>
</li>
{/if}
</ul>
<div class="py-2">
<button
on:click={() => {
dropdownUser();
ShowVersion();
}}
class="block px-4 py-2 w-full text-sm hover:bg-gray-600 text-gray-200 over:text-white"
>
Version
</button>
<button
on:click={() => runtime.Quit()}
class="block px-4 py-2 w-full text-sm hover:bg-gray-600 text-gray-200 over:text-white"
>
Exit Application
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import {
apiError,
isApiDown,
clearApiError,
setApiError,
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
import { CheckIfMALLoggedInAndSetUser } from "../helperModules/CheckIfMyAnimeListLoggedIn.svelte";
import { CheckIfSimklLoggedInAndSetUser } from "../helperModules/CheckIsSimklLoggedIn.svelte";
import { Modal, Button } from "flowbite-svelte";
let showModal = false;
$: if ($apiError) {
showModal = true;
}
async function handleRetry() {
const service = $apiError?.service;
clearApiError();
try {
if (service === "anilist") {
await CheckIfAniListLoggedInAndLoadWatchList();
} else if (service === "mal") {
await CheckIfMALLoggedInAndSetUser();
} else if (service === "simkl") {
await CheckIfSimklLoggedInAndSetUser();
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
setApiError(
service || "unknown",
`Retry failed: ${errorMsg}`,
undefined,
true,
);
}
}
function handleDismiss() {
clearApiError();
showModal = false;
}
</script>
{#if showModal && $apiError}
<Modal
open={showModal}
title="{$apiError.service.toUpperCase()} API Error"
size="md"
>
<div class="space-y-4">
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-800 font-medium">{$apiError.message}</p>
{#if $apiError.statusCode}
<p class="text-red-600 text-sm mt-2">
Status: {$apiError.statusCode}
</p>
{/if}
</div>
<p class="text-gray-600">
The application will remain open. You can retry the connection or
dismiss this message to continue with limited functionality.
</p>
</div>
<div slot="footer" class="flex gap-3 justify-end">
{#if $apiError.canRetry}
<Button on:click={handleRetry} class="bg-blue-600 hover:bg-blue-700">
Retry Connection
</Button>
{/if}
<Button on:click={handleDismiss} color="alternative">Dismiss</Button>
</div>
</Modal>
{/if}

View File

@@ -1,145 +1,220 @@
<script lang="ts"> <script lang="ts">
import { import {
aniListLoggedIn, aniListLoggedIn,
aniListWatchlist, aniListSort,
animePerPage, aniListWatchlist,
watchListPage, animePerPage,
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte"; watchListPage,
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
import type {AniListCurrentUserWatchList} from "../anilist/types/AniListCurrentUserWatchListType" import type { AniListCurrentUserWatchList } from "../anilist/types/AniListCurrentUserWatchListType";
import {GetAniListUserWatchingList} from "../../wailsjs/go/main/App"; import { GetAniListUserWatchingList } from "../../wailsjs/go/main/App";
import {MediaListSort} from "../anilist/types/AniListTypes";
let aniListWatchListLoaded: AniListCurrentUserWatchList let aniListWatchListLoaded: AniListCurrentUserWatchList;
let page: number let page: number;
let perPage: number let perPage: number;
let sort: string;
watchListPage.subscribe(value => page = value) watchListPage.subscribe((value) => (page = value));
animePerPage.subscribe(value => perPage = value) animePerPage.subscribe((value) => (perPage = value));
aniListWatchlist.subscribe((value) => aniListWatchListLoaded = value) aniListWatchlist.subscribe((value) => (aniListWatchListLoaded = value));
aniListSort.subscribe((value) => (sort = value));
const perPageOptions = [10, 20, 50] const perPageOptions = [10, 20, 50];
function ChangeWatchListPage(newPage: number) { function ChangeWatchListPage(newPage: number) {
GetAniListUserWatchingList(newPage, perPage, MediaListSort.UpdatedTimeDesc).then((result) => { GetAniListUserWatchingList(newPage, perPage, sort).then((result) => {
watchListPage.set(newPage) watchListPage.set(newPage);
aniListWatchlist.set(result) aniListWatchlist.set(result);
aniListLoggedIn.set(true) aniListLoggedIn.set(true);
}) });
} }
function changePage(e): void { function changePage(
if ((e.key === "Enter" || e.key === "Tab") && Number(e.target.value) !== page) ChangeWatchListPage(Number(e.target.value)) e: KeyboardEvent & { currentTarget: HTMLInputElement },
} ): void {
if (
function changeCountPerPage(e): void { (e.key === "Enter" || e.key === "Tab") &&
GetAniListUserWatchingList(1, Number(e.target.value), MediaListSort.UpdatedTimeDesc).then((result) => { Number(e.currentTarget.value) !== page
animePerPage.set(Number(e.target.value)) )
watchListPage.set(1) ChangeWatchListPage(Number(e.currentTarget.value));
aniListWatchlist.set(result) }
aniListLoggedIn.set(true)
})
}
function changeCountPerPage(
e: Event & { currentTarget: HTMLSelectElement },
): void {
GetAniListUserWatchingList(1, Number(e.currentTarget.value), sort).then(
(result) => {
animePerPage.set(Number(e.currentTarget.value));
watchListPage.set(1);
aniListWatchlist.set(result);
aniListLoggedIn.set(true);
},
);
}
</script> </script>
<div class="mb-8"> <div class="mb-8">
{#if aniListWatchListLoaded.data.Page.pageInfo.lastPage <= 12} {#if aniListWatchListLoaded.data.Page.pageInfo.lastPage <= 12}
<nav aria-label="Page navigation" class="hidden md:block"> <nav aria-label="Page navigation" class="hidden md:block">
<ul class="inline-flex -space-x-px text-base h-10"> <ul class="inline-flex -space-x-px text-base h-10">
{#if page === 1} {#if page === 1}
<li> <li>
<button disabled <button
class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 cursor-default"> disabled
Previous class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 cursor-default"
</button> >
</li> Previous
{:else} </button>
<li> </li>
<button on:click={() => ChangeWatchListPage(page-1)} {:else}
class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"> <li>
Previous <button
</button> on:click={() => ChangeWatchListPage(page - 1)}
</li> class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"
{/if} >
{#each {length: aniListWatchListLoaded.data.Page.pageInfo.lastPage} as _, i} Previous
{#if i + 1 === page} </button>
<li> </li>
<button on:click={() => ChangeWatchListPage(i+1)} {/if}
class="flex items-center justify-center px-4 h-10 leading-tight border bg-gray-100 border-gray-700 bg-gray-700 text-white">{i + 1}</button> {#each { length: aniListWatchListLoaded.data.Page.pageInfo.lastPage } as _, i}
</li> {#if i + 1 === page}
{:else} <li>
<li> <button
<button on:click={() => ChangeWatchListPage(i+1)} on:click={() => ChangeWatchListPage(i + 1)}
class="flex items-center justify-center px-4 h-10 leading-tight border dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white">{i + 1}</button> class="flex items-center justify-center px-4 h-10 leading-tight border hover:bg-gray-100 border-gray-700 bg-gray-700 text-white"
</li> >{i + 1}</button
{/if} >
{/each} </li>
{#if page === aniListWatchListLoaded.data.Page.pageInfo.lastPage} {:else}
<li> <li>
<button disabled <button
class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 cursor-default"> on:click={() => ChangeWatchListPage(i + 1)}
Next class="flex items-center justify-center px-4 h-10 leading-tight border dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"
</button> >{i + 1}</button
</li> >
{:else} </li>
<li> {/if}
<button on:click={() => ChangeWatchListPage(page+1)} {/each}
class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"> {#if page === aniListWatchListLoaded.data.Page.pageInfo.lastPage}
Next <li>
</button> <button
</li> disabled
{/if} class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 cursor-default"
</ul> >
</nav> Next
{/if} </button>
<div class="flex mt-5"> </li>
<div class="w-20 mx-auto"> {:else}
<select bind:value={perPage} on:change={(e) => changeCountPerPage(e)} id="countPerPage" <li>
class="border text-sm rounded-lg block w-full p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"> <button
{#each perPageOptions as option} on:click={() => ChangeWatchListPage(page + 1)}
<option value={option}> class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"
{option} >
</option> Next
{/each} </button>
</select> </li>
</div> {/if}
</ul>
<div> </nav>
<div>Total Anime: {aniListWatchListLoaded.data.Page.pageInfo.total}</div> {/if}
{#if aniListWatchListLoaded.data.Page.pageInfo.lastPage <= 12} <div class="flex mt-5">
<div class="md:hidden">Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}</div> <div class="w-20 mx-auto">
{:else} <select
<div>Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}</div> bind:value={perPage}
{/if} on:change={(e) => changeCountPerPage(e)}
</div> id="countPerPage"
class="border text-sm rounded-lg block w-full p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
<div class="max-w-xs mx-auto"> >
<div class="relative flex items-center max-w-[11rem]"> {#each perPageOptions as option}
<button type="button" id="decrement-button" on:click={() => ChangeWatchListPage(page-1)} <option value={option}>
class="bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"> {option}
<svg class="w-3 h-3 text-white" aria-hidden="true" </option>
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 2"> {/each}
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" </select>
d="M1 1h16"/>
</svg>
</button>
<input type="number" min="1" max="{aniListWatchListLoaded.data.Page.pageInfo.lastPage}"
on:keydown={changePage} id="page-counter"
class="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none border-x-0 h-11 font-medium text-center text-sm block w-full pb-6 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
value={page} required/>
<div class="absolute bottom-1 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 flex items-center text-xs text-gray-400 space-x-1 rtl:space-x-reverse">
<span>Page #</span>
</div>
<button type="button" id="increment-button" on:click={() => ChangeWatchListPage(page+1)}
class="hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none">
<svg class="w-3 h-3 text-white" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 18">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 1v16M1 9h16"/>
</svg>
</button>
</div>
</div>
</div> </div>
</div>
<div>
<div>Total Anime: {aniListWatchListLoaded.data.Page.pageInfo.total}</div>
{#if aniListWatchListLoaded.data.Page.pageInfo.lastPage <= 12}
<div class="md:hidden">
Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}
</div>
{:else}
<div>
Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}
</div>
{/if}
</div>
<div class="max-w-xs mx-auto">
<div class="relative flex items-center max-w-[11rem]">
<button
type="button"
id="decrement-button"
on:click={() => ChangeWatchListPage(page - 1)}
class={page <= 1
? "border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
disabled={page <= 1}
>
<svg
class="w-3 h-3 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 18 2"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M1 1h16"
/>
</svg>
</button>
<input
type="number"
min="1"
max={aniListWatchListLoaded.data.Page.pageInfo.lastPage}
on:keydown={changePage}
id="page-counter"
class="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none border-x-0 h-11 font-medium text-center text-sm block w-full pb-6 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
value={page}
required
/>
<div
class="absolute bottom-1 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 flex items-center text-xs text-gray-400 space-x-1 rtl:space-x-reverse"
>
<span>Page #</span>
</div>
<button
type="button"
id="increment-button"
on:click={() => ChangeWatchListPage(page + 1)}
class={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage
? "border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
disabled={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage}
>
<svg
class="w-3 h-3 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 18 18"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 1v16M1 9h16"
/>
</svg>
</button>
</div>
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@
}} }}
> >
<img <img
class="rounded-lg" class="rounded-lg w-[230px] h-[330px] object-cover"
src={media.media.coverImage.large} src={media.media.coverImage.large}
alt={media.media.title.english === "" alt={media.media.title.english === ""
? media.media.title.romaji ? media.media.title.romaji

View File

@@ -1,34 +1,81 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import {CheckIfAniListLoggedIn, GetAniListLoggedInUser, GetAniListUserWatchingList} from "../../wailsjs/go/main/App"; import {
import {MediaListSort} from "../anilist/types/AniListTypes"; CheckIfAniListLoggedIn,
import { aniListUser, watchListPage, animePerPage, aniListPrimary, aniListLoggedIn, aniListWatchlist } from "./GlobalVariablesAndHelperFunctions.svelte" GetAniListLoggedInUser,
GetAniListUserWatchingList,
} from "../../wailsjs/go/main/App";
import {
aniListUser,
watchListPage,
animePerPage,
aniListPrimary,
aniListLoggedIn,
aniListWatchlist,
aniListSort,
clearApiError,
setApiError,
} from "./GlobalVariablesAndHelperFunctions.svelte";
let isAniListPrimary: boolean let isAniListPrimary: boolean;
let page: number let page: number;
let perPage: number let perPage: number;
let sort: string;
aniListPrimary.subscribe(value => isAniListPrimary = value) aniListPrimary.subscribe((value) => (isAniListPrimary = value));
watchListPage.subscribe(value => page = value) watchListPage.subscribe((value) => (page = value));
animePerPage.subscribe(value => perPage = value) animePerPage.subscribe((value) => (perPage = value));
aniListSort.subscribe((value) => (sort = value));
export const LoadAniListUser = async () => { export const LoadAniListUser = async () => {
await GetAniListLoggedInUser().then(user => { try {
aniListUser.set(user) await GetAniListLoggedInUser().then((user) => {
}) aniListUser.set(user);
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
setApiError(
"anilist",
`Failed to load user: ${errorMsg}`,
undefined,
true,
);
throw err;
} }
};
export const LoadAniListWatchList = async () => { export const LoadAniListWatchList = async () => {
await GetAniListUserWatchingList(page, perPage, MediaListSort.UpdatedTimeDesc).then((watchList) => { try {
aniListWatchlist.set(watchList) const watchList = await GetAniListUserWatchingList(page, perPage, sort);
}) aniListWatchlist.set(watchList);
clearApiError();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
setApiError(
"anilist",
`Failed to load watch list: ${errorMsg}`,
undefined,
true,
);
throw err;
} }
};
export const CheckIfAniListLoggedInAndLoadWatchList = async () => { export const CheckIfAniListLoggedInAndLoadWatchList = async () => {
const loggedIn = await CheckIfAniListLoggedIn() try {
if (loggedIn) { const loggedIn = await CheckIfAniListLoggedIn();
await LoadAniListUser() if (loggedIn) {
if (isAniListPrimary) await LoadAniListWatchList() await LoadAniListUser();
} if (isAniListPrimary) await LoadAniListWatchList();
aniListLoggedIn.set(loggedIn) }
aniListLoggedIn.set(loggedIn);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
setApiError(
"anilist",
`Authentication failed: ${errorMsg}`,
undefined,
true,
);
aniListLoggedIn.set(false);
} }
</script> };
</script>

View File

@@ -53,6 +53,7 @@
export let loading = writable(false); export let loading = writable(false);
export let tableItems = writable([] as TableItems); export let tableItems = writable([] as TableItems);
export let watchlistNeedsRefresh = writable(false); export let watchlistNeedsRefresh = writable(false);
export let aniListSort = writable(MediaListSort.UpdatedTimeDesc);
export let watchListPage = writable(1); export let watchListPage = writable(1);
export let animePerPage = writable(20); export let animePerPage = writable(20);
@@ -60,6 +61,7 @@
let isAniListPrimary: boolean; let isAniListPrimary: boolean;
let page: number; let page: number;
let perPage: number; let perPage: number;
let sort: string;
let aniWatchlist: AniListCurrentUserWatchList; let aniWatchlist: AniListCurrentUserWatchList;
let currentAniListAnime: AniListGetSingleAnime; let currentAniListAnime: AniListGetSingleAnime;
@@ -73,6 +75,34 @@
malLoggedIn.subscribe((value) => (isMalLoggedIn = value)); malLoggedIn.subscribe((value) => (isMalLoggedIn = value));
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value)); simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
aniListAnime.subscribe((value) => (currentAniListAnime = value)); aniListAnime.subscribe((value) => (currentAniListAnime = value));
aniListSort.subscribe((value) => (sort = value));
export interface ApiError {
service: string;
message: string;
statusCode?: string;
canRetry: boolean;
}
export const apiError = writable<ApiError | null>(null);
export const isApiDown = writable(false);
export function setApiError(
service: string,
message: string,
statusCode?: string,
canRetry: boolean = true,
) {
apiError.set({
service,
message,
statusCode,
canRetry,
});
isApiDown.set(true);
}
export function clearApiError() {
apiError.set(null);
isApiDown.set(false);
}
export async function GetAnimeSingleItem( export async function GetAnimeSingleItem(
aniId: number, aniId: number,
@@ -136,11 +166,7 @@
GetAniListLoggedInUser().then((result) => { GetAniListLoggedInUser().then((result) => {
aniListUser.set(result); aniListUser.set(result);
if (isAniListPrimary) { if (isAniListPrimary) {
GetAniListUserWatchingList( GetAniListUserWatchingList(page, perPage, sort).then((result) => {
page,
perPage,
MediaListSort.UpdatedTimeDesc,
).then((result) => {
aniListWatchlist.set(result); aniListWatchlist.set(result);
aniListLoggedIn.set(true); aniListLoggedIn.set(true);
}); });
@@ -184,4 +210,3 @@
}); });
} }
</script> </script>

View File

@@ -1,25 +1,55 @@
<script lang="ts"> <script lang="ts">
import Pagination from "../helperComponents/Pagination.svelte"; import Pagination from "../helperComponents/Pagination.svelte";
import WatchList from "../helperComponents/WatchList.svelte"; import WatchList from "../helperComponents/WatchList.svelte";
import { import {
aniListLoggedIn, aniListLoggedIn,
aniListPrimary, aniListPrimary,
loading, loading,
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte"; isApiDown,
import loader from '../helperFunctions/loader' apiError,
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
import loader from "../helperFunctions/loader";
let isAniListPrimary: boolean let isAniListPrimary: boolean;
let isAniListLoggedIn: boolean let isAniListLoggedIn: boolean;
aniListPrimary.subscribe((value) => isAniListPrimary = value) aniListPrimary.subscribe((value) => (isAniListPrimary = value));
aniListLoggedIn.subscribe((value) => isAniListLoggedIn = value) aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
</script> </script>
{#if isAniListLoggedIn && isAniListPrimary}
<div class="container py-10"> {#if $isApiDown}
<div class="container py-10">
<div
class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center"
>
<svg
class="mx-auto h-12 w-12 text-yellow-600 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="text-xl font-semibold text-yellow-900 mb-2">
API Unavailable
</h2>
<p class="text-yellow-700 mb-4">
The {$apiError?.service || "service"} is currently unavailable. The app will
remain open, and you can retry when the service is back online.
</p>
</div>
</div>
{:else if isAniListLoggedIn && isAniListPrimary}
<div class="container py-10">
<Pagination /> <Pagination />
<WatchList /> <WatchList />
<Pagination /> <Pagination />
</div> </div>
{:else} {:else}
<div use:loader={loading}></div> <div use:loader={loading}></div>
{/if} {/if}

View File

@@ -1,10 +0,0 @@
// vite.config.ts
import { defineConfig } from "file:///home/nymusicman/Code/AniTrack/frontend/node_modules/vite/dist/node/index.js";
import { svelte } from "file:///home/nymusicman/Code/AniTrack/frontend/node_modules/@sveltejs/vite-plugin-svelte/src/index.js";
var vite_config_default = defineConfig({
plugins: [svelte()]
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9ueW11c2ljbWFuL0NvZGUvQW5pVHJhY2svZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL255bXVzaWNtYW4vQ29kZS9BbmlUcmFjay9mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9ueW11c2ljbWFuL0NvZGUvQW5pVHJhY2svZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQge2RlZmluZUNvbmZpZ30gZnJvbSAndml0ZSdcbmltcG9ydCB7c3ZlbHRlfSBmcm9tICdAc3ZlbHRlanMvdml0ZS1wbHVnaW4tc3ZlbHRlJ1xuXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgcGx1Z2luczogW3N2ZWx0ZSgpXVxufSlcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBdVMsU0FBUSxvQkFBbUI7QUFDbFUsU0FBUSxjQUFhO0FBR3JCLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxPQUFPLENBQUM7QUFDcEIsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K

View File

@@ -12,6 +12,6 @@
}, },
"info": { "info": {
"productName": "AniTrack", "productName": "AniTrack",
"productVersion": "0.6.0" "productVersion": "0.7.0"
} }
} }