ui: add pending and catalog placeholders
This commit is contained in:
5
internal/features/anime/errors.go
Normal file
5
internal/features/anime/errors.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrAnimePendingFetch = errors.New("anime pending fetch")
|
||||||
@@ -2,6 +2,7 @@ package anime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -100,13 +101,18 @@ func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
page := parsePageParam(r)
|
page := parsePageParam(r)
|
||||||
|
|
||||||
res, err := h.svc.GetTopAnime(r.Context(), page)
|
res, fallbackPlaceholder, err := h.svc.GetTopAnimeWithPlaceholder(r.Context(), page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("top anime error: %v", err)
|
log.Printf("top anime error: %v", err)
|
||||||
http.Error(w, "Failed to fetch top anime", http.StatusInternalServerError)
|
http.Error(w, "Failed to fetch top anime", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fallbackPlaceholder {
|
||||||
|
templates.CatalogPlaceholderItems(24).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
res.Animes = deduplicateAnimes(res.Animes)
|
res.Animes = deduplicateAnimes(res.Animes)
|
||||||
|
|
||||||
templates.CatalogItems(res.Animes, page+1, res.HasNextPage).Render(r.Context(), w)
|
templates.CatalogItems(res.Animes, page+1, res.HasNextPage).Render(r.Context(), w)
|
||||||
@@ -124,6 +130,16 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
anime, currentStatus, err := h.svc.GetAnimeDetails(r.Context(), id, userID)
|
anime, currentStatus, err := h.svc.GetAnimeDetails(r.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAnimePendingFetch) {
|
||||||
|
templates.AnimePending(id).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jikan.IsNotFoundError(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("anime fetch error for %d: %v", id, err)
|
log.Printf("anime fetch error for %d: %v", id, err)
|
||||||
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ func (s *Service) GetTopAnime(ctx context.Context, page int) (jikan.TopAnimeResu
|
|||||||
return s.jikanClient.GetTopAnime(ctx, page)
|
return s.jikanClient.GetTopAnime(ctx, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTopAnimeWithPlaceholder(ctx context.Context, page int) (jikan.TopAnimeResult, bool, error) {
|
||||||
|
result, err := s.jikanClient.GetTopAnime(ctx, page)
|
||||||
|
if err == nil {
|
||||||
|
return result, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if jikan.IsRetryableError(err) {
|
||||||
|
return jikan.TopAnimeResult{}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return jikan.TopAnimeResult{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetAiringAnime(ctx context.Context, page int) (jikan.TopAnimeResult, error) {
|
func (s *Service) GetAiringAnime(ctx context.Context, page int) (jikan.TopAnimeResult, error) {
|
||||||
return s.jikanClient.GetSeasonsNow(ctx, page)
|
return s.jikanClient.GetSeasonsNow(ctx, page)
|
||||||
}
|
}
|
||||||
@@ -40,6 +53,15 @@ func (s *Service) GetUpcomingAnime(ctx context.Context, page int) (jikan.TopAnim
|
|||||||
func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (jikan.Anime, string, error) {
|
func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (jikan.Anime, string, error) {
|
||||||
anime, err := s.jikanClient.GetAnimeByID(ctx, id)
|
anime, err := s.jikanClient.GetAnimeByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jikan.IsNotFoundError(err) {
|
||||||
|
return jikan.Anime{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jikanClient.EnqueueAnimeFetchRetry(ctx, id, err)
|
||||||
|
if jikan.IsRetryableError(err) {
|
||||||
|
return jikan.Anime{}, "", ErrAnimePendingFetch
|
||||||
|
}
|
||||||
|
|
||||||
return jikan.Anime{}, "", fmt.Errorf("failed to fetch anime details: %w", err)
|
return jikan.Anime{}, "", fmt.Errorf("failed to fetch anime details: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +97,9 @@ func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templa
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
anime, err := s.jikanClient.GetAnimeByID(ctx, int(row.AnimeID))
|
anime, err := s.jikanClient.GetAnimeByID(ctx, int(row.AnimeID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jikan.IsRetryableError(err) {
|
||||||
|
s.jikanClient.EnqueueAnimeFetchRetry(ctx, int(row.AnimeID), err)
|
||||||
|
}
|
||||||
// Instead of skipping, we still append it, but without the extra Jikan details
|
// Instead of skipping, we still append it, but without the extra Jikan details
|
||||||
// This prevents anime from vanishing from the watchlist when Jikan rate limits us.
|
// This prevents anime from vanishing from the watchlist when Jikan rate limits us.
|
||||||
anime = jikan.Anime{}
|
anime = jikan.Anime{}
|
||||||
|
|||||||
@@ -165,6 +165,25 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ AnimePending(id int) {
|
||||||
|
@Layout("mal - anime pending", true) {
|
||||||
|
<div class="anime-page">
|
||||||
|
<div class="anime-main">
|
||||||
|
<section class="anime-surface anime-section">
|
||||||
|
<h1>Anime data is being fetched</h1>
|
||||||
|
<p class="empty-inline-note">We could not load this anime right now. A background worker is retrying data fetch for anime #{ fmt.Sprintf("%d", id) }.</p>
|
||||||
|
<p class="empty-inline-note">Refresh this page in a few seconds.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.reload()
|
||||||
|
}, 10000)
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func joinNames(entities []jikan.NamedEntity) string {
|
func joinNames(entities []jikan.NamedEntity) string {
|
||||||
names := make([]string, len(entities))
|
names := make([]string, len(entities))
|
||||||
for i, e := range entities {
|
for i, e := range entities {
|
||||||
|
|||||||
@@ -17,3 +17,12 @@ templ Catalog() {
|
|||||||
templ CatalogItems(animes []jikan.Anime, nextPage int, hasNext bool) {
|
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")
|
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/catalog?page=%d", nextPage))), "catalog-content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ CatalogPlaceholderItems(count int) {
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
<div class="catalog-item catalog-placeholder" aria-hidden="true">
|
||||||
|
<div class="catalog-placeholder-thumb"></div>
|
||||||
|
<div class="catalog-placeholder-title"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -295,6 +295,37 @@ main {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-placeholder {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-placeholder-thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-height: var(--poster-max-height);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
background: linear-gradient(90deg, var(--surface-search) 0%, rgba(255, 255, 255, 0.08) 45%, var(--surface-search) 100%);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: placeholder-shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-placeholder-title {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
height: 0.9rem;
|
||||||
|
width: 80%;
|
||||||
|
background: linear-gradient(90deg, var(--surface-search) 0%, rgba(255, 255, 255, 0.08) 45%, var(--surface-search) 100%);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: placeholder-shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes placeholder-shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-item > a,
|
.catalog-item > a,
|
||||||
.catalog-item a,
|
.catalog-item a,
|
||||||
.relation-card,
|
.relation-card,
|
||||||
|
|||||||
Reference in New Issue
Block a user