diff --git a/api/anime/handler.go b/api/anime/handler.go index 2568d0d..1c934f5 100644 --- a/api/anime/handler.go +++ b/api/anime/handler.go @@ -83,9 +83,17 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) { // We'll skip DB fetch for continue watching for now if it requires complex session parsing // Actually we should try to fetch it if we can. var cw []database.GetContinueWatchingEntriesRow + watchlistMap := make(map[int64]bool) + var watchlistIDs []int64 user, userOk := r.Context().Value(ctxpkg.UserKey).(*database.User) if userOk && user != nil { cw, _ = h.db.GetContinueWatchingEntries(r.Context(), user.ID) + watchlist, _ := h.db.GetUserWatchList(r.Context(), user.ID) + watchlistIDs = make([]int64, len(watchlist)) + for i, entry := range watchlist { + watchlistMap[entry.AnimeID] = true + watchlistIDs[i] = entry.AnimeID + } } if err := templates.GetRenderer().ExecuteTemplate(w, "index.gohtml", map[string]any{ @@ -94,6 +102,8 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) { "ContinueWatching": cw, "User": user, "CurrentPath": r.URL.Path, + "WatchlistMap": watchlistMap, + "WatchlistIDs": watchlistIDs, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -114,15 +124,28 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) { log.Printf("browse error: %v", err) } + watchlistMap := make(map[int64]bool) + var watchlistIDs []int64 + if user != nil { + watchlist, _ := h.db.GetUserWatchList(r.Context(), user.ID) + watchlistIDs = make([]int64, len(watchlist)) + for i, entry := range watchlist { + watchlistMap[entry.AnimeID] = true + watchlistIDs[i] = entry.AnimeID + } + } + if err := templates.GetRenderer().ExecuteTemplate(w, "browse.gohtml", map[string]any{ - "User": user, - "CurrentPath": r.URL.Path, - "Query": q, - "Type": animeType, - "Status": status, - "OrderBy": orderBy, - "Sort": sort, - "Animes": res.Animes, + "User": user, + "CurrentPath": r.URL.Path, + "Query": q, + "Type": animeType, + "Status": status, + "OrderBy": orderBy, + "Sort": sort, + "Animes": res.Animes, + "WatchlistMap": watchlistMap, + "WatchlistIDs": watchlistIDs, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/api/watchlist/handler.go b/api/watchlist/handler.go index a1014de..417d598 100644 --- a/api/watchlist/handler.go +++ b/api/watchlist/handler.go @@ -1,9 +1,13 @@ package watchlist import ( + "encoding/json" "log" "net/http" + "strconv" + ctxpkg "mal/internal/context" + database "mal/internal/db" "mal/templates" ) @@ -20,11 +24,61 @@ func (h *Handler) HandleCardWatchlist(w http.ResponseWriter, r *http.Request) { } func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Not implemented", http.StatusNotImplemented) + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User) + if user == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var body struct { + AnimeID int64 `json:"animeId"` + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if body.Status == "" { + body.Status = "plan_to_watch" + } + + if err := h.service.AddToWatchlist(r.Context(), user.ID, body.AnimeID, body.Status); err != nil { + log.Printf("failed to add to watchlist: %v", err) + http.Error(w, "failed to add to watchlist", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) } func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Not implemented", http.StatusNotImplemented) + user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User) + if user == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + animeIDStr := r.URL.Path[len("/api/watchlist/"):] + animeID, err := strconv.ParseInt(animeIDStr, 10, 64) + if err != nil { + http.Error(w, "invalid anime id", http.StatusBadRequest) + return + } + + if _, err := h.service.RemoveEntry(r.Context(), user.ID, animeID); err != nil { + log.Printf("failed to remove from watchlist: %v", err) + http.Error(w, "failed to remove from watchlist", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/watchlist") + w.WriteHeader(http.StatusOK) } func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) { @@ -32,9 +86,55 @@ func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Re } func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) { - if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{ - "CurrentPath": r.URL.Path, - }); err != nil { + user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + entries, err := h.service.GetUserWatchlist(r.Context(), user.ID) + if err != nil { + log.Printf("failed to fetch watchlist: %v", err) + if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{ + "CurrentPath": r.URL.Path, + }); err != nil { + log.Printf("render error: %v", err) + } + return + } + + watchlistByStatus := make(map[string][]database.GetUserWatchListRow) + allEntries := make([]database.GetUserWatchListRow, 0) + + for _, entry := range entries { + status := entry.Status + if status == "" { + status = "plan_to_watch" + } + watchlistByStatus[status] = append(watchlistByStatus[status], entry) + allEntries = append(allEntries, entry) + } + + data := map[string]any{ + "CurrentPath": r.URL.Path, + "WatchlistByStatus": watchlistByStatus, + "AllEntries": allEntries, + "StatusOrder": []string{"watching", "plan_to_watch", "on_hold", "completed", "dropped"}, + "StatusLabels": map[string]string{ + "watching": "Currently Watching", + "plan_to_watch": "Plan to Watch", + "on_hold": "On Hold", + "completed": "Completed", + "dropped": "Dropped", + }, + } + + templateName := "watchlist.gohtml" + if r.Header.Get("HX-Request") == "true" { + templateName = "watchlist_partial.gohtml" + } + + if err := templates.GetRenderer().ExecuteTemplate(w, templateName, data); err != nil { log.Printf("render error: %v", err) } } diff --git a/api/watchlist/service.go b/api/watchlist/service.go index f2f18fc..ffe3db5 100644 --- a/api/watchlist/service.go +++ b/api/watchlist/service.go @@ -9,12 +9,14 @@ import ( "github.com/google/uuid" + "mal/integrations/jikan" "mal/internal/db" ) type Service struct { - db database.Querier - sqlDB *sql.DB + db database.Querier + sqlDB *sql.DB + jikanClient *jikan.Client } var ( @@ -23,13 +25,41 @@ var ( ) var validStatuses = map[string]struct{}{ + "watching": {}, "completed": {}, "dropped": {}, "plan_to_watch": {}, + "on_hold": {}, } -func NewService(db database.Querier, sqlDB *sql.DB) *Service { - return &Service{db: db, sqlDB: sqlDB} +func NewService(db database.Querier, sqlDB *sql.DB, jikanClient *jikan.Client) *Service { + return &Service{db: db, sqlDB: sqlDB, jikanClient: jikanClient} +} + +func (s *Service) ensureAnimeExists(ctx context.Context, animeID int64) error { + _, err := s.db.GetAnime(ctx, animeID) + if err == nil { + return nil + } + + anime, err := s.jikanClient.GetAnimeByID(ctx, int(animeID)) + if err != nil { + return fmt.Errorf("failed to fetch anime from jikan: %w", err) + } + + _, err = s.db.UpsertAnime(ctx, database.UpsertAnimeParams{ + ID: int64(anime.MalID), + TitleOriginal: anime.Title, + TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, + ImageUrl: anime.Images.Jpg.LargeImageURL, + Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + }) + if err != nil { + return fmt.Errorf("failed to save anime: %w", err) + } + + return nil } type AddRequest struct { @@ -42,34 +72,26 @@ type AddRequest struct { Airing bool } -func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) error { - if req.AnimeID <= 0 { +func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int64, status string) error { + if animeID <= 0 { return ErrInvalidAnimeID } - if _, ok := validStatuses[req.Status]; !ok { + if _, ok := validStatuses[status]; !ok { return ErrInvalidStatus } - _, err := s.db.UpsertAnime(ctx, database.UpsertAnimeParams{ - ID: req.AnimeID, - TitleOriginal: req.TitleOriginal, - TitleEnglish: sql.NullString{String: req.TitleEnglish, Valid: req.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: req.TitleJapanese, Valid: req.TitleJapanese != ""}, - ImageUrl: req.ImageURL, - Airing: sql.NullBool{Bool: req.Airing, Valid: true}, - }) - if err != nil { - return fmt.Errorf("failed to save anime reference: %w", err) + if err := s.ensureAnimeExists(ctx, animeID); err != nil { + return err } entryID := uuid.New().String() - _, err = s.db.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{ + _, err := s.db.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{ ID: entryID, UserID: userID, - AnimeID: req.AnimeID, - Status: req.Status, - CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, + AnimeID: animeID, + Status: status, + CurrentEpisode: sql.NullInt64{Valid: false}, CurrentTimeSeconds: 0, }) if err != nil { diff --git a/internal/server/routes.go b/internal/server/routes.go index 8826a7e..b1a12d6 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -46,7 +46,7 @@ func NewRouter(cfg Config) http.Handler { authHandler := auth.NewHandler(cfg.AuthService) - watchlistSvc := watchlist.NewService(cfg.DB, cfg.SQLDB) + watchlistSvc := watchlist.NewService(cfg.DB, cfg.SQLDB, cfg.JikanClient) watchlistHandler := watchlist.NewHandler(watchlistSvc) middleware.InitAuth(cfg.AuthService) diff --git a/migrations/014_add_watchlist_statuses.sql b/migrations/014_add_watchlist_statuses.sql new file mode 100644 index 0000000..b0d8bd1 --- /dev/null +++ b/migrations/014_add_watchlist_statuses.sql @@ -0,0 +1,26 @@ +-- Add "watching" and "on_hold" to the valid statuses for watch_list_entry + +PRAGMA foreign_keys=OFF; + +ALTER TABLE watch_list_entry RENAME TO watch_list_entry_old; + +CREATE TABLE IF NOT EXISTS watch_list_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK(status IN ('watching', 'completed', 'dropped', 'plan_to_watch', 'on_hold')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + current_episode INTEGER DEFAULT 0, + last_episode_at DATETIME, + current_time_seconds REAL NOT NULL DEFAULT 0, + UNIQUE(user_id, anime_id) +); + +INSERT OR IGNORE INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds) +SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds +FROM watch_list_entry_old; + +DROP TABLE watch_list_entry_old; + +PRAGMA foreign_keys=ON; diff --git a/static/dropdown.ts b/static/dropdown.ts index 14a89d0..135ca69 100644 --- a/static/dropdown.ts +++ b/static/dropdown.ts @@ -1,6 +1,7 @@ class UIDropdown extends HTMLElement { isOpen: boolean = false contentEl: HTMLElement | null = null + isClosing: boolean = false constructor() { super() @@ -28,6 +29,7 @@ class UIDropdown extends HTMLElement { } toggle() { + if (this.isClosing) return this.isOpen = !this.isOpen if (this.contentEl) { if (this.isOpen) { @@ -39,10 +41,15 @@ class UIDropdown extends HTMLElement { } close() { + if (this.isClosing) return + this.isClosing = true this.isOpen = false if (this.contentEl) { this.contentEl.classList.add('hidden') } + setTimeout(() => { + this.isClosing = false + }, 100) } handleClickOutside(event: MouseEvent) { diff --git a/static/style.css b/static/style.css index 9d30521..dba94b2 100644 --- a/static/style.css +++ b/static/style.css @@ -95,7 +95,11 @@ html, body { .scrollbar-hide::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } - .scrollbar-hide { +button.in-watchlist .watchlist-icon { + fill: currentColor !important; +} + +.scrollbar-hide { -ms-overflow-style: auto; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05); diff --git a/templates/base.gohtml b/templates/base.gohtml index 68db8f7..2ec5362 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -26,6 +26,47 @@ document.getElementById('mobile-overlay').classList.add('hidden'); } } + + const watchlistIds = new Set() + + function initWatchlist(ids) { + ids.forEach(id => watchlistIds.add(id)) + } + + function toggleWatchlist(id, btn) { + const isInWatchlist = watchlistIds.has(id) + const url = isInWatchlist ? `/api/watchlist/${id}` : '/api/watchlist' + const method = isInWatchlist ? 'DELETE' : 'POST' + const body = isInWatchlist ? null : JSON.stringify({ animeId: id, status: 'plan_to_watch' }) + fetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body + }).then(res => { + if (res.ok) { + if (isInWatchlist) { + watchlistIds.delete(id) + btn.classList.remove('in-watchlist') + btn.setAttribute('aria-label', 'Add to Watchlist') + } else { + watchlistIds.add(id) + btn.classList.add('in-watchlist') + btn.setAttribute('aria-label', 'Remove from Watchlist') + } + } + }) + } + + function removeFromWatchlist(id, btn) { + fetch(`/api/watchlist/${id}`, { method: 'DELETE' }).then(res => { + if (res.ok) { + watchlistIds.delete(id) + const card = btn.closest('.group').parentElement + if (card) card.remove() + setTimeout(() => location.reload(), 100) + } + }) + } diff --git a/templates/browse.gohtml b/templates/browse.gohtml index dffa0b1..dc94027 100644 --- a/templates/browse.gohtml +++ b/templates/browse.gohtml @@ -1,5 +1,6 @@ {{define "title"}}Browse{{end}} {{define "content"}} +{{if .WatchlistIDs}}{{end}}

Browse

@@ -16,7 +17,7 @@ {{else}}
{{range .Animes}} - {{template "anime_card" dict "Anime" . "WithActions" true}} + {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} {{end}}
{{end}} diff --git a/templates/components/anime_card.gohtml b/templates/components/anime_card.gohtml index e85e7ea..df35902 100644 --- a/templates/components/anime_card.gohtml +++ b/templates/components/anime_card.gohtml @@ -24,14 +24,6 @@ {{if $withActions}}
- {{if $isWatchlist}} -
- -
- {{end}} - {{if not $isWatchlist}}

{{$displayTitle}} @@ -44,13 +36,11 @@

{{end}} - {{if not $isWatchlist}} -
- -
- {{end}} +
+ +

{{else}}
@@ -62,22 +52,22 @@ {{if $anime.Score}} - {{$anime.Score}} - - {{end}} - {{if $anime.Year}} - - {{$anime.Year}} - {{end}} - {{if $anime.Episodes}} - - {{$anime.Episodes}} ep - {{end}} -
- {{end}} -
- {{end}} - + {{$anime.Score}} + + {{end}} + {{if $anime.Year}} + + {{$anime.Year}} + {{end}} + {{if $anime.Episodes}} + + {{$anime.Episodes}} ep + {{end}} +
+ {{end}} + + {{end}} + {{if and $withActions (not $hideTitle)}}

{{$displayTitle}} diff --git a/templates/components/watchlist_actions.gohtml b/templates/components/watchlist_actions.gohtml index 772e72c..882fd2a 100644 --- a/templates/components/watchlist_actions.gohtml +++ b/templates/components/watchlist_actions.gohtml @@ -6,7 +6,7 @@
- - - - @@ -52,15 +52,22 @@
{{end}}
{{if .ContinueWatching}} {{template "continue_watching" .ContinueWatching}} @@ -16,7 +17,7 @@
{{range .CurrentlyAiring}} - {{template "anime_card" dict "Anime" . "WithActions" true}} + {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} {{end}}
@@ -32,7 +33,7 @@
{{range .MostPopular}} - {{template "anime_card" dict "Anime" . "WithActions" true}} + {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} {{end}}
diff --git a/templates/watchlist.gohtml b/templates/watchlist.gohtml new file mode 100644 index 0000000..0c36439 --- /dev/null +++ b/templates/watchlist.gohtml @@ -0,0 +1,45 @@ +{{define "title"}}Watchlist{{end}} +{{define "content"}} +
+ {{range $status := $.StatusOrder}} + {{$entries := index $.WatchlistByStatus $status}} + {{if $entries}} +
+
+

{{index $.StatusLabels $status}}

+
+ +
+ {{range $entries}} +
+
+ + {{.DisplayTitle}} + +
+
+ +
+
+
+

+ {{.DisplayTitle}} +

+
+ {{end}} +
+
+ {{end}} + {{end}} + + {{if eq (len $.AllEntries) 0}} +
+ +

Your watchlist is empty.

+ Browse anime +
+ {{end}} +
+{{end}} diff --git a/templates/watchlist_partial.gohtml b/templates/watchlist_partial.gohtml new file mode 100644 index 0000000..c2d9ae8 --- /dev/null +++ b/templates/watchlist_partial.gohtml @@ -0,0 +1,45 @@ +{{define "title"}}Watchlist{{end}} +{{define "content"}} +
+ {{range $status := $.StatusOrder}} + {{$entries := index $.WatchlistByStatus $status}} + {{if $entries}} +
+
+

{{index $.StatusLabels $status}}

+
+ +
+ {{range $entries}} +
+
+ + {{.DisplayTitle}} + +
+
+ +
+
+
+

+ {{.DisplayTitle}} +

+
+ {{end}} +
+
+ {{end}} + {{end}} + + {{if eq (len $.AllEntries) 0}} +
+ +

Your watchlist is empty.

+ Browse anime +
+ {{end}} +
+{{end}} \ No newline at end of file