feat: add top picks for you page
This commit is contained in:
@@ -121,6 +121,7 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||||
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||||
r.GET("/discover", h.HandleDiscover)
|
r.GET("/discover", h.HandleDiscover)
|
||||||
|
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
|
||||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||||
@@ -302,6 +303,37 @@ func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleDiscoverTopPicksForYou(c *gin.Context) {
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
|
||||||
|
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"top_picks_for_you_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||||
|
"_fragment": "",
|
||||||
|
"CurrentPath": "/discover",
|
||||||
|
"User": user,
|
||||||
|
"Animes": data.Animes,
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
"IsTopPicks": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
||||||
h.renderDiscoverSection(c, "Trending")
|
h.renderDiscoverSection(c, "Trending")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
forYouMaxRecommendations = 10
|
forYouMaxRecommendations = 10
|
||||||
forYouCandidateFetchLimit = 60
|
forYouCandidateFetchLimit = 60
|
||||||
forYouResultLimit = 18
|
forYouResultLimit = 18
|
||||||
|
forYouFullResultLimit = 60
|
||||||
forYouProfileSearchLimit = 8
|
forYouProfileSearchLimit = 8
|
||||||
forYouProfileGenreSearches = 2
|
forYouProfileGenreSearches = 2
|
||||||
forYouProfileThemeSearches = 2
|
forYouProfileThemeSearches = 2
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||||
|
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||||
|
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) getTopPicksForYou(
|
||||||
|
ctx context.Context,
|
||||||
|
userID string,
|
||||||
|
resultLimit int,
|
||||||
|
) (domain.CatalogSectionData, error) {
|
||||||
if strings.TrimSpace(userID) == "" {
|
if strings.TrimSpace(userID) == "" {
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
}
|
}
|
||||||
@@ -342,7 +354,7 @@ func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (dom
|
|||||||
})
|
})
|
||||||
|
|
||||||
return domain.CatalogSectionData{
|
return domain.CatalogSectionData{
|
||||||
Animes: rerankRecommendationCandidates(candidates, forYouResultLimit),
|
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ type ReviewEntry struct {
|
|||||||
type AnimeCatalogService interface {
|
type AnimeCatalogService interface {
|
||||||
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
||||||
GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||||
|
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnimeDiscoverService interface {
|
type AnimeDiscoverService interface {
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
{{define "title"}}Discover{{end}}
|
{{define "title"}}Discover{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
{{if .IsTopPicks}}
|
||||||
|
<div class="flex flex-col gap-6 pb-12">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-xl font-normal tracking-[-0.02em] text-foreground">Top Picks for You</h1>
|
||||||
|
<p class="mt-1 text-sm text-foreground-muted">The full ranked list from your current watchlist profile.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/discover" class="text-sm text-foreground-muted transition-colors hover:text-foreground">Back to Discover</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq (len .Animes) 0}}
|
||||||
|
<div class="flex h-64 flex-col items-center justify-center gap-3 text-foreground-muted">
|
||||||
|
<svg class="h-10 w-10 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /></svg>
|
||||||
|
<p class="text-sm text-foreground">No top picks yet</p>
|
||||||
|
<p class="text-sm">Add a few anime to your watchlist so the recommender has something to learn from.</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<a href="/watchlist" class="!rounded-none inline-flex h-10 items-center justify-center bg-background-button px-4 text-sm font-normal text-foreground transition-colors hover:bg-background-button-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent">Open watchlist</a>
|
||||||
|
<a href="/discover" class="!rounded-none inline-flex h-10 items-center justify-center bg-background-button px-4 text-sm font-normal text-foreground transition-colors hover:bg-background-button-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent">Discover anime</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
|
||||||
|
{{range .Animes}}
|
||||||
|
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
<div class="flex flex-col gap-12 pb-12">
|
<div class="flex flex-col gap-12 pb-12">
|
||||||
<section class="flex flex-col items-center justify-center bg-background-surface px-6 py-16 text-center">
|
<section class="flex flex-col items-center justify-center bg-background-surface px-6 py-16 text-center">
|
||||||
<div class="flex max-w-2xl flex-col items-center gap-5">
|
<div class="flex max-w-2xl flex-col items-center gap-5">
|
||||||
@@ -65,6 +94,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "discover_section"}}
|
{{define "discover_section"}}
|
||||||
<div class="discover-grid">
|
<div class="discover-grid">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<section id="top-pick-for-you-section" class="w-full" data-top-pick-carousel>
|
<section id="top-pick-for-you-section" class="w-full" data-top-pick-carousel>
|
||||||
<div class="mb-4 flex items-end justify-between gap-3">
|
<div class="mb-4 flex items-end justify-between gap-3">
|
||||||
<h2 class="min-w-0 text-base font-normal text-foreground">Top Pick for You</h2>
|
<h2 class="min-w-0 text-base font-normal text-foreground">Top Pick for You</h2>
|
||||||
<a href="/discover" class="group flex items-center gap-1 text-sm text-foreground-muted transition-colors hover:text-foreground">
|
<a href="/discover/top-picks" class="group flex items-center gap-1 text-sm text-foreground-muted transition-colors hover:text-foreground">
|
||||||
Explore more
|
Explore more
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user