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)
}
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) {
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)
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
}
@@ -125,7 +141,8 @@ func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) {
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) {
@@ -134,7 +151,8 @@ func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
result, err := h.jikanClient.GetTopAnime(r.Context(), page)
if err == nil {
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
}
@@ -211,6 +229,7 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
return
}
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
switch section {
case "relations":
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.")
return
}
animecomponents.RelationsList(relations).Render(r.Context(), w)
animecomponents.RelationsList(relations, statuses).Render(r.Context(), w)
case "recommendations":
recs, err := h.jikanClient.GetRecommendations(r.Context(), id, 12)
if err != nil {
@@ -230,7 +249,7 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
writeInlineLoadError(w, "Failed to load recommendations.")
return
}
animecomponents.Recommendations(recs).Render(r.Context(), w)
animecomponents.Recommendations(recs, statuses).Render(r.Context(), w)
case "episodes":
currentEpisode := r.URL.Query().Get("current")
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)
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) {
@@ -359,7 +379,8 @@ func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Reque
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) {
@@ -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)
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
// 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
}
http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError)
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) {
@@ -427,5 +449,6 @@ func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) {
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"
)
templ Recommendations(recs []jikan.Anime) {
templ Recommendations(recs []jikan.Anime, watchlistStatuses map[int]string) {
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">
for _, anime := range recs {
@ui.AnimeCard(ui.AnimeCardProps{
ID: anime.MalID,
Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing,
Synopsis: anime.Synopsis,
ID: anime.MalID,
Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing,
Synopsis: anime.Synopsis,
WatchlistStatus: watchlistStatuses[anime.MalID],
})
}
</div>

View File

@@ -5,18 +5,19 @@ import (
ui "mal/web/components"
)
templ RelationsList(relations []jikan.RelationEntry) {
templ RelationsList(relations []jikan.RelationEntry, watchlistStatuses map[int]string) {
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">
for _, rel := range relations {
@ui.AnimeCard(ui.AnimeCardProps{
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,
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,
WatchlistStatus: watchlistStatuses[rel.Anime.MalID],
}) {
if rel.IsCurrent {
<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>
}
}

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">
<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>
</a>
}

View File

@@ -5,10 +5,10 @@ import (
"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 {
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@CatalogItem(anime)
@CatalogItem(anime, watchlistStatuses[anime.MalID])
</div>
}
if hasNext {
@@ -29,15 +29,16 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
</script>
}
templ CatalogItem(anime jikan.Anime) {
templ CatalogItem(anime jikan.Anime, watchlistStatus string) {
@AnimeCard(AnimeCardProps{
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),
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),
WatchlistStatus: watchlistStatus,
})
}

View File

@@ -12,7 +12,7 @@ templ CardButton(
inWatchlist bool,
) {
<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 {
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) }

View File

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

View File

@@ -47,6 +47,6 @@ templ Discover() {
}
}
templ DiscoverItems(animes []jikan.Anime, listType string, nextPage int, hasNext bool) {
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/discover/%s?page=%d", listType, nextPage))), "discover-content")
templ DiscoverItems(animes []jikan.Anime, watchlistStatuses map[int]string, listType string, nextPage int, hasNext bool) {
@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 {
@ui.EmptyState("No results found.") {
Try a different search term.
}
} else {
<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>
}
}
templ SearchItems(query string, animes []jikan.Anime, nextPage int, hasNext bool) {
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/search?q=%s&page=%d", url.QueryEscape(query), nextPage))), "results")
templ SearchItems(query string, animes []jikan.Anime, watchlistStatuses map[int]string, nextPage int, hasNext bool) {
@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"
)
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) {
<div class="grid gap-5">
<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 {
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@components.AnimeCard(components.AnimeCardProps{
ID: anime.MalID,
Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing,
ID: anime.MalID,
Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing,
WatchlistStatus: watchlistStatuses[anime.MalID],
})
</div>
}
@@ -72,16 +73,17 @@ templ StudioLoadMore(studioID int, nextPage int) {
></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 {
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@components.AnimeCard(components.AnimeCardProps{
ID: anime.MalID,
Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing,
ID: anime.MalID,
Title: anime.DisplayTitle(),
ImageURL: anime.ImageURL(),
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
Airing: anime.Airing,
WatchlistStatus: watchlistStatuses[anime.MalID],
})
</div>
}