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 != "" { + + + Play + + + + } + 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, })
}