feat: persist watchlist status on anime cards with white bookmark and outline play button

This commit is contained in:
2026-04-21 00:41:23 +02:00
parent a5f2628d1e
commit cd8df7d2bf
10 changed files with 89 additions and 63 deletions

View File

@@ -81,6 +81,21 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
templates.Catalog().Render(r.Context(), w) templates.Catalog().Render(r.Context(), w)
} }
func (h *Handler) watchlistMap(ctx context.Context, userID string) map[int]string {
if userID == "" {
return nil
}
entries, err := h.db.GetUserWatchList(ctx, userID)
if err != nil {
return nil
}
m := make(map[int]string, len(entries))
for _, e := range entries {
m[int(e.AnimeID)] = e.Status
}
return m
}
func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Vary", "HX-Request") w.Header().Set("Vary", "HX-Request")
@@ -101,7 +116,8 @@ func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to search anime", http.StatusInternalServerError) http.Error(w, "Failed to search anime", http.StatusInternalServerError)
return return
} }
templates.SearchResultsWrapper(query, res.Animes, 2, res.HasNextPage).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.SearchResultsWrapper(query, res.Animes, statuses, 2, res.HasNextPage).Render(r.Context(), w)
return return
} }
@@ -125,7 +141,8 @@ func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) {
res.Animes = deduplicateAnimes(res.Animes) res.Animes = deduplicateAnimes(res.Animes)
templates.SearchItems(query, res.Animes, page+1, res.HasNextPage).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.SearchItems(query, res.Animes, statuses, page+1, res.HasNextPage).Render(r.Context(), w)
} }
func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
@@ -134,7 +151,8 @@ func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
result, err := h.jikanClient.GetTopAnime(r.Context(), page) result, err := h.jikanClient.GetTopAnime(r.Context(), page)
if err == nil { if err == nil {
result.Animes = deduplicateAnimes(result.Animes) result.Animes = deduplicateAnimes(result.Animes)
templates.CatalogItems(result.Animes, page+1, result.HasNextPage).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.CatalogItems(result.Animes, statuses, page+1, result.HasNextPage).Render(r.Context(), w)
return return
} }
@@ -211,6 +229,7 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
return return
} }
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
switch section { switch section {
case "relations": case "relations":
relations, err := h.jikanClient.GetFullRelations(r.Context(), id) relations, err := h.jikanClient.GetFullRelations(r.Context(), id)
@@ -222,7 +241,7 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
writeInlineLoadError(w, "Failed to load relations.") writeInlineLoadError(w, "Failed to load relations.")
return return
} }
animecomponents.RelationsList(relations).Render(r.Context(), w) animecomponents.RelationsList(relations, statuses).Render(r.Context(), w)
case "recommendations": case "recommendations":
recs, err := h.jikanClient.GetRecommendations(r.Context(), id, 12) recs, err := h.jikanClient.GetRecommendations(r.Context(), id, 12)
if err != nil { if err != nil {
@@ -230,7 +249,7 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
writeInlineLoadError(w, "Failed to load recommendations.") writeInlineLoadError(w, "Failed to load recommendations.")
return return
} }
animecomponents.Recommendations(recs).Render(r.Context(), w) animecomponents.Recommendations(recs, statuses).Render(r.Context(), w)
case "episodes": case "episodes":
currentEpisode := r.URL.Query().Get("current") currentEpisode := r.URL.Query().Get("current")
episodes, err := h.getEpisodes(r.Context(), id) episodes, err := h.getEpisodes(r.Context(), id)
@@ -344,7 +363,8 @@ func (h *Handler) HandleAPIDiscoverAiring(w http.ResponseWriter, r *http.Request
res.Animes = deduplicateAnimes(res.Animes) res.Animes = deduplicateAnimes(res.Animes)
templates.DiscoverItems(res.Animes, "airing", page+1, res.HasNextPage).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.DiscoverItems(res.Animes, statuses, "airing", page+1, res.HasNextPage).Render(r.Context(), w)
} }
func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Request) {
@@ -359,7 +379,8 @@ func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Reque
res.Animes = deduplicateAnimes(res.Animes) res.Animes = deduplicateAnimes(res.Animes)
templates.DiscoverItems(res.Animes, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.DiscoverItems(res.Animes, statuses, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w)
} }
func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) {
@@ -387,14 +408,15 @@ func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) {
log.Printf("studio anime fetch error for %d: %v", id, err) log.Printf("studio anime fetch error for %d: %v", id, err)
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) { if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
// Render page with empty anime list if API is rate limiting // Render page with empty anime list if API is rate limiting
templates.StudioDetails(producer, []jikan.Anime{}, false, 2).Render(r.Context(), w) templates.StudioDetails(producer, []jikan.Anime{}, nil, false, 2).Render(r.Context(), w)
return return
} }
http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError) http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError)
return return
} }
templates.StudioDetails(producer, result.Animes, result.HasNextPage, 2).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.StudioDetails(producer, result.Animes, statuses, result.HasNextPage, 2).Render(r.Context(), w)
} }
func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) {
@@ -427,5 +449,6 @@ func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) {
result.Animes = deduplicateAnimes(result.Animes) result.Animes = deduplicateAnimes(result.Animes)
templates.StudioAnimeItems(result.Animes, result.HasNextPage, id, page+1).Render(r.Context(), w) statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
templates.StudioAnimeItems(result.Animes, statuses, result.HasNextPage, id, page+1).Render(r.Context(), w)
} }

View File

@@ -5,18 +5,19 @@ import (
ui "mal/web/components" ui "mal/web/components"
) )
templ Recommendations(recs []jikan.Anime) { templ Recommendations(recs []jikan.Anime, watchlistStatuses map[int]string) {
if len(recs) > 0 { if len(recs) > 0 {
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-6"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-6">
for _, anime := range recs { for _, anime := range recs {
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: anime.MalID, ID: anime.MalID,
Title: anime.DisplayTitle(), Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(), ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish, TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese, TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing, Airing: anime.Airing,
Synopsis: anime.Synopsis, Synopsis: anime.Synopsis,
WatchlistStatus: watchlistStatuses[anime.MalID],
}) })
} }
</div> </div>

View File

@@ -5,18 +5,19 @@ import (
ui "mal/web/components" ui "mal/web/components"
) )
templ RelationsList(relations []jikan.RelationEntry) { templ RelationsList(relations []jikan.RelationEntry, watchlistStatuses map[int]string) {
if len(relations) > 1 { if len(relations) > 1 {
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-6" id="relations-grid"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-6" id="relations-grid">
for _, rel := range relations { for _, rel := range relations {
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: rel.Anime.MalID, ID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(), Title: rel.Anime.DisplayTitle(),
ImageURL: rel.Anime.ImageURL(), ImageURL: rel.Anime.ImageURL(),
TitleEnglish: rel.Anime.TitleEnglish, TitleEnglish: rel.Anime.TitleEnglish,
TitleJapanese: rel.Anime.TitleJapanese, TitleJapanese: rel.Anime.TitleJapanese,
Airing: rel.Anime.Airing, Airing: rel.Anime.Airing,
CurrentNode: rel.IsCurrent, CurrentNode: rel.IsCurrent,
WatchlistStatus: watchlistStatuses[rel.Anime.MalID],
}) { }) {
if rel.IsCurrent { if rel.IsCurrent {
<div class="mt-2 h-0.5 w-10 bg-white"></div> <div class="mt-2 h-0.5 w-10 bg-white"></div>
@@ -31,5 +32,3 @@ templ RelationsList(relations []jikan.RelationEntry) {
<p class="text-sm text-(--text-muted)">No related anime found.</p> <p class="text-sm text-(--text-muted)">No related anime found.</p>
} }
} }

View File

@@ -87,7 +87,7 @@ templ animeCardPoster(props AnimeCardProps) {
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Play</title> <title>Play</title>
<path d="M8 5V19L19 12L8 5Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/> <path d="M8 5V19L19 12L8 5Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg> </svg>
</a> </a>
} }

View File

@@ -5,10 +5,10 @@ import (
"mal/integrations/jikan" "mal/integrations/jikan"
) )
templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, containerID string) { templ InfiniteAnimeList(animes []jikan.Anime, watchlistStatuses map[int]string, hasNext bool, nextURL string, containerID string) {
for _, anime := range animes { for _, anime := range animes {
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }> <div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@CatalogItem(anime) @CatalogItem(anime, watchlistStatuses[anime.MalID])
</div> </div>
} }
if hasNext { if hasNext {
@@ -29,15 +29,16 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
</script> </script>
} }
templ CatalogItem(anime jikan.Anime) { templ CatalogItem(anime jikan.Anime, watchlistStatus string) {
@AnimeCard(AnimeCardProps{ @AnimeCard(AnimeCardProps{
ID: anime.MalID, ID: anime.MalID,
Title: anime.DisplayTitle(), Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(), ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish, TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese, TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing, Airing: anime.Airing,
Synopsis: anime.Synopsis, Synopsis: anime.Synopsis,
PlayHref: fmt.Sprintf("/watch/%d/1", anime.MalID), PlayHref: fmt.Sprintf("/watch/%d/1", anime.MalID),
WatchlistStatus: watchlistStatus,
}) })
} }

View File

@@ -12,7 +12,7 @@ templ CardButton(
inWatchlist bool, inWatchlist bool,
) { ) {
<button <button
class={ "cursor-pointer border-0 bg-transparent p-0", templ.KV("text-blue-500", inWatchlist), templ.KV("text-white hover:text-blue-400", !inWatchlist) } class={ "cursor-pointer border-0 bg-transparent p-0", templ.KV("text-white", inWatchlist), templ.KV("text-white hover:text-white/70", !inWatchlist) }
if !inWatchlist { if !inWatchlist {
hx-post="/api/watchlist/card" hx-post="/api/watchlist/card"
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "airing": "%v"}`, animeID, title, titleEnglish, titleJapanese, imageURL, airing) } hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "airing": "%v"}`, animeID, title, titleEnglish, titleJapanese, imageURL, airing) }

View File

@@ -15,8 +15,8 @@ templ Catalog() {
} }
} }
templ CatalogItems(animes []jikan.Anime, nextPage int, hasNext bool) { templ CatalogItems(animes []jikan.Anime, watchlistStatuses map[int]string, nextPage int, hasNext bool) {
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/catalog?page=%d", nextPage))), "catalog-content") @ui.InfiniteAnimeList(animes, watchlistStatuses, hasNext, string(templ.URL(fmt.Sprintf("/api/catalog?page=%d", nextPage))), "catalog-content")
} }
templ CatalogPlaceholderItems(count int) { templ CatalogPlaceholderItems(count int) {

View File

@@ -47,6 +47,6 @@ templ Discover() {
} }
} }
templ DiscoverItems(animes []jikan.Anime, listType string, nextPage int, hasNext bool) { templ DiscoverItems(animes []jikan.Anime, watchlistStatuses map[int]string, listType string, nextPage int, hasNext bool) {
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/discover/%s?page=%d", listType, nextPage))), "discover-content") @ui.InfiniteAnimeList(animes, watchlistStatuses, hasNext, string(templ.URL(fmt.Sprintf("/api/discover/%s?page=%d", listType, nextPage))), "discover-content")
} }

View File

@@ -23,18 +23,18 @@ templ Search(q string) {
} }
} }
templ SearchResultsWrapper(query string, animes []jikan.Anime, nextPage int, hasNext bool) { templ SearchResultsWrapper(query string, animes []jikan.Anime, watchlistStatuses map[int]string, nextPage int, hasNext bool) {
if len(animes) == 0 { if len(animes) == 0 {
@ui.EmptyState("No results found.") { @ui.EmptyState("No results found.") {
Try a different search term. Try a different search term.
} }
} else { } else {
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
@SearchItems(query, animes, nextPage, hasNext) @SearchItems(query, animes, watchlistStatuses, nextPage, hasNext)
</div> </div>
} }
} }
templ SearchItems(query string, animes []jikan.Anime, nextPage int, hasNext bool) { templ SearchItems(query string, animes []jikan.Anime, watchlistStatuses map[int]string, nextPage int, hasNext bool) {
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/search?q=%s&page=%d", url.QueryEscape(query), nextPage))), "results") @ui.InfiniteAnimeList(animes, watchlistStatuses, hasNext, string(templ.URL(fmt.Sprintf("/api/search?q=%s&page=%d", url.QueryEscape(query), nextPage))), "results")
} }

View File

@@ -9,7 +9,7 @@ import (
"mal/web/shared/layout" "mal/web/shared/layout"
) )
templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, hasNext bool, nextPage int) { templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, watchlistStatuses map[int]string, hasNext bool, nextPage int) {
@layout.Layout("mal - "+shared.GetProducerName(producer), true) { @layout.Layout("mal - "+shared.GetProducerName(producer), true) {
<div class="grid gap-5"> <div class="grid gap-5">
<div class="grid gap-4 bg-(--panel) p-4"> <div class="grid gap-4 bg-(--panel) p-4">
@@ -45,12 +45,13 @@ templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, hasNe
for _, anime := range animes { for _, anime := range animes {
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }> <div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@components.AnimeCard(components.AnimeCardProps{ @components.AnimeCard(components.AnimeCardProps{
ID: anime.MalID, ID: anime.MalID,
Title: anime.DisplayTitle(), Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(), ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish, TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese, TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing, Airing: anime.Airing,
WatchlistStatus: watchlistStatuses[anime.MalID],
}) })
</div> </div>
} }
@@ -72,16 +73,17 @@ templ StudioLoadMore(studioID int, nextPage int) {
></div> ></div>
} }
templ StudioAnimeItems(animes []jikan.Anime, hasNext bool, studioID int, nextPage int) { templ StudioAnimeItems(animes []jikan.Anime, watchlistStatuses map[int]string, hasNext bool, studioID int, nextPage int) {
for _, anime := range animes { for _, anime := range animes {
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }> <div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@components.AnimeCard(components.AnimeCardProps{ @components.AnimeCard(components.AnimeCardProps{
ID: anime.MalID, ID: anime.MalID,
Title: anime.DisplayTitle(), Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(), ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish, TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese, TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing, Airing: anime.Airing,
WatchlistStatus: watchlistStatuses[anime.MalID],
}) })
</div> </div>
} }