diff --git a/api/watchlist/handler.go b/api/watchlist/handler.go
index f087734..f068fb4 100644
--- a/api/watchlist/handler.go
+++ b/api/watchlist/handler.go
@@ -88,6 +88,60 @@ func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request)
watchlist.WatchlistDropdown(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing).Render(r.Context(), w)
}
+func (h *Handler) HandleCardWatchlist(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodPost) {
+ return
+ }
+
+ user := middleware.GetUser(r.Context())
+ if user == nil {
+ w.Header().Set("HX-Redirect", "/login")
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+
+ animeIDStr := r.FormValue("anime_id")
+ animeTitle := r.FormValue("anime_title")
+ animeTitleEnglish := r.FormValue("anime_title_english")
+ animeTitleJapanese := r.FormValue("anime_title_japanese")
+ animeImage := r.FormValue("anime_image")
+ airingStr := r.FormValue("airing")
+ airing := airingStr == "true"
+
+ animeID, err := strconv.ParseInt(animeIDStr, 10, 64)
+ if err != nil || animeID <= 0 {
+ http.Error(w, "invalid anime ID", http.StatusBadRequest)
+ return
+ }
+
+ req := AddRequest{
+ AnimeID: animeID,
+ TitleOriginal: animeTitle,
+ TitleEnglish: animeTitleEnglish,
+ TitleJapanese: animeTitleJapanese,
+ ImageURL: animeImage,
+ Status: "plan_to_watch",
+ Airing: airing,
+ }
+
+ if err := h.svc.AddEntry(r.Context(), user.ID, req); err != nil {
+ if errors.Is(err, ErrInvalidAnimeID) || errors.Is(err, ErrInvalidStatus) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ log.Printf("watchlist card add failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
+ http.Error(w, "failed to update watchlist", http.StatusInternalServerError)
+ return
+ }
+
+ watchlist.CardButton(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, airing, true).Render(r.Context(), w)
+}
+
func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodDelete) {
return
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 41f90ba..14aedbd 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -78,6 +78,7 @@ func NewRouter(cfg Config) http.Handler {
// Watchlist Endpoints
mux.HandleFunc("/api/watchlist/export", watchlistHandler.HandleExportWatchlist)
mux.HandleFunc("/api/watchlist/import", watchlistHandler.HandleImportWatchlist)
+ mux.HandleFunc("/api/watchlist/card", watchlistHandler.HandleCardWatchlist)
mux.HandleFunc("/api/watchlist", watchlistHandler.HandleUpdateWatchlist)
mux.HandleFunc("/api/watchlist/", watchlistHandler.HandleDeleteWatchlist)
mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching)
diff --git a/web/components/anime/recommendations.templ b/web/components/anime/recommendations.templ
index 4b0048a..98f70d2 100644
--- a/web/components/anime/recommendations.templ
+++ b/web/components/anime/recommendations.templ
@@ -10,10 +10,13 @@ templ Recommendations(recs []jikan.Anime) {
for _, anime := range recs {
@ui.AnimeCard(ui.AnimeCardProps{
- ID: anime.MalID,
- Title: anime.DisplayTitle(),
- ImageURL: anime.ImageURL(),
- Synopsis: anime.Synopsis,
+ ID: anime.MalID,
+ Title: anime.DisplayTitle(),
+ ImageURL: anime.ImageURL(),
+ TitleEnglish: anime.TitleEnglish,
+ TitleJapanese: anime.TitleJapanese,
+ Airing: anime.Airing,
+ Synopsis: anime.Synopsis,
})
}
diff --git a/web/components/anime/relations.templ b/web/components/anime/relations.templ
index 7d7a489..63167a3 100644
--- a/web/components/anime/relations.templ
+++ b/web/components/anime/relations.templ
@@ -10,10 +10,13 @@ templ RelationsList(relations []jikan.RelationEntry) {
for _, rel := range relations {
@ui.AnimeCard(ui.AnimeCardProps{
- ID: rel.Anime.MalID,
- Title: rel.Anime.DisplayTitle(),
- ImageURL: rel.Anime.ImageURL(),
- CurrentNode: rel.IsCurrent,
+ ID: rel.Anime.MalID,
+ Title: rel.Anime.DisplayTitle(),
+ ImageURL: rel.Anime.ImageURL(),
+ TitleEnglish: rel.Anime.TitleEnglish,
+ TitleJapanese: rel.Anime.TitleJapanese,
+ Airing: rel.Anime.Airing,
+ CurrentNode: rel.IsCurrent,
}) {
if rel.IsCurrent {
diff --git a/web/components/anime_card.templ b/web/components/anime_card.templ
index 5e7d4ae..b4e64ca 100644
--- a/web/components/anime_card.templ
+++ b/web/components/anime_card.templ
@@ -1,6 +1,10 @@
package ui
-import "fmt"
+import (
+ "fmt"
+
+ "mal/web/components/watchlist"
+)
type AnimeCardProps struct {
ID int
@@ -15,6 +19,11 @@ type AnimeCardProps struct {
CurrentNode bool // if true, renders a div instead of an anchor tag (useful for graph nodes)
Synopsis string // optional synopsis for hover detail
PlayHref string // optional play button href (anchored to poster)
+ // Watchlist integration
+ TitleEnglish string
+ TitleJapanese string
+ Airing bool
+ WatchlistStatus string // empty if not in watchlist
}
templ AnimeCard(props AnimeCardProps) {
@@ -68,16 +77,32 @@ templ animeCardPoster(props AnimeCardProps) {
}
- if props.PlayHref != "" {
-
-
-
+ if props.PlayHref != "" || !props.CurrentNode {
+
+ if props.PlayHref != "" {
+
+
+
+ }
+ if !props.CurrentNode {
+ @watchlist.CardButton(
+ props.ID,
+ props.Title,
+ props.TitleEnglish,
+ props.TitleJapanese,
+ props.ImageURL,
+ props.Airing,
+ props.WatchlistStatus != "",
+ )
+ }
+
}
}
diff --git a/web/components/anime_list.templ b/web/components/anime_list.templ
index 4ff897d..8c4569d 100644
--- a/web/components/anime_list.templ
+++ b/web/components/anime_list.templ
@@ -31,10 +31,13 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
templ CatalogItem(anime jikan.Anime) {
@AnimeCard(AnimeCardProps{
- ID: anime.MalID,
- Title: anime.DisplayTitle(),
- ImageURL: anime.ImageURL(),
- Synopsis: anime.Synopsis,
- PlayHref: fmt.Sprintf("/watch/%d/1", anime.MalID),
+ ID: anime.MalID,
+ Title: anime.DisplayTitle(),
+ ImageURL: anime.ImageURL(),
+ TitleEnglish: anime.TitleEnglish,
+ TitleJapanese: anime.TitleJapanese,
+ Airing: anime.Airing,
+ Synopsis: anime.Synopsis,
+ PlayHref: fmt.Sprintf("/watch/%d/1", anime.MalID),
})
}
diff --git a/web/components/watchlist/card_button.templ b/web/components/watchlist/card_button.templ
new file mode 100644
index 0000000..413f9a6
--- /dev/null
+++ b/web/components/watchlist/card_button.templ
@@ -0,0 +1,43 @@
+package watchlist
+
+import "fmt"
+
+templ CardButton(
+ animeID int,
+ title string,
+ titleEnglish string,
+ titleJapanese string,
+ imageURL string,
+ airing bool,
+ inWatchlist bool,
+) {
+
+}
+
+func getWatchlistFill(inWatchlist bool) string {
+ if inWatchlist {
+ return "currentColor"
+ }
+ return "none"
+}
+
+func getWatchlistLabel(inWatchlist bool) string {
+ if inWatchlist {
+ return "In watchlist"
+ }
+ return "Add to watchlist"
+}
diff --git a/web/templates/continue_watching.templ b/web/templates/continue_watching.templ
index 63b1a98..449eca3 100644
--- a/web/templates/continue_watching.templ
+++ b/web/templates/continue_watching.templ
@@ -1,6 +1,7 @@
package templates
import (
+ "database/sql"
"fmt"
db "mal/internal/db"
ui "mal/web/components"
@@ -21,14 +22,17 @@ templ ContinueWatching(entries []db.GetContinueWatchingEntriesRow) {
for _, entry := range entries {
- @ui.AnimeCard(ui.AnimeCardProps{
- ID: int(entry.AnimeID),
- Title: displayContinueWatchingTitle(entry),
- ImageURL: entry.ImageUrl,
- Href: continueWatchingURL(entry),
- Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
- HideTitle: true,
- }) {
+ @ui.AnimeCard(ui.AnimeCardProps{
+ ID: int(entry.AnimeID),
+ Title: displayContinueWatchingTitle(entry),
+ ImageURL: entry.ImageUrl,
+ Href: continueWatchingURL(entry),
+ TitleEnglish: nullString(entry.TitleEnglish),
+ TitleJapanese: nullString(entry.TitleJapanese),
+ WatchlistStatus: "watching",
+ Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
+ HideTitle: true,
+ }) {
{ displayContinueWatchingTitle(entry) }
@@ -67,3 +71,10 @@ func continueWatchingURL(entry db.GetContinueWatchingEntriesRow) string {
func displayContinueWatchingTitle(entry db.GetContinueWatchingEntriesRow) string {
return db.DisplayTitle(entry.TitleEnglish, entry.TitleJapanese, entry.TitleOriginal)
}
+
+func nullString(s sql.NullString) string {
+ if s.Valid {
+ return s.String
+ }
+ return ""
+}
diff --git a/web/templates/studio.templ b/web/templates/studio.templ
index e4f50ca..8849b1c 100644
--- a/web/templates/studio.templ
+++ b/web/templates/studio.templ
@@ -45,9 +45,12 @@ templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, hasNe
for _, anime := range animes {
@components.AnimeCard(components.AnimeCardProps{
- ID: anime.MalID,
- Title: anime.DisplayTitle(),
- ImageURL: anime.ImageURL(),
+ ID: anime.MalID,
+ Title: anime.DisplayTitle(),
+ ImageURL: anime.ImageURL(),
+ TitleEnglish: anime.TitleEnglish,
+ TitleJapanese: anime.TitleJapanese,
+ Airing: anime.Airing,
})
}
@@ -73,9 +76,12 @@ templ StudioAnimeItems(animes []jikan.Anime, hasNext bool, studioID int, nextPag
for _, anime := range animes {
@components.AnimeCard(components.AnimeCardProps{
- ID: anime.MalID,
- Title: anime.DisplayTitle(),
- ImageURL: anime.ImageURL(),
+ ID: anime.MalID,
+ Title: anime.DisplayTitle(),
+ ImageURL: anime.ImageURL(),
+ TitleEnglish: anime.TitleEnglish,
+ TitleJapanese: anime.TitleJapanese,
+ Airing: anime.Airing,
})
}