feat: add simulcast page with season picker and navigation

This commit is contained in:
2026-06-30 19:29:18 +02:00
parent 4afa0b46f9
commit 86ba058825
7 changed files with 101 additions and 5 deletions

View File

@@ -4,10 +4,39 @@ import (
"mal/internal/observability"
"mal/internal/server"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func (h *AnimeHandler) HandleSimulcast(c *gin.Context) {
now := time.Now()
current := calendarSeason(now.Year(), int(now.Month()))
latest := h.discoverySvc.LatestAvailableSeason(c.Request.Context(), current)
selected := seasonSelection(c.Query("season"), c.Query("year"), current, latest)
data, err := h.discoverySvc.GetSimulcast(c.Request.Context(), selected)
if err != nil {
observability.WarnContext(c.Request.Context(), "simulcast_fetch_failed", "anime", "", nil, err)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), server.CurrentUserID(c), data.Animes)
previous, next := seasonNavigation(selected, 2018, latest)
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
"CurrentPath": "/simulcast",
"User": server.CurrentUser(c),
"Animes": data.Animes,
"Season": data.Season,
"SeasonLabel": strings.ToUpper(data.Season[:1]) + data.Season[1:],
"Year": data.Year,
"SeasonOptions": seasonOptions(2018, latest),
"Previous": previous,
"Next": next,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
c.HTML(http.StatusOK, "search.gohtml", gin.H{
"User": server.CurrentUser(c),

View File

@@ -11,6 +11,7 @@ type AnimeHandler struct {
svc Service
watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService
discoverySvc *SeasonDiscoveryService
}
type Service interface {
@@ -20,11 +21,12 @@ type Service interface {
WarmDetailSections(id int)
}
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService, discoverySvc *SeasonDiscoveryService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
discoverySvc: discoverySvc,
}
}
@@ -59,6 +61,7 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/search", h.HandleSearch)
r.GET("/top-picks", h.HandleTopPicks)
r.GET("/browse", h.HandleBrowse)
r.GET("/simulcast", h.HandleSimulcast)
r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)

View File

@@ -140,7 +140,7 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
@@ -162,7 +162,7 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
@@ -279,7 +279,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
},
err: tt.err,
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
Anime: jikan.Anime{MalID: 52991},

View File

@@ -10,6 +10,7 @@ import (
var Module = fx.Options(
fx.Provide(
NewAnimeRepository,
NewSeasonDiscoveryService,
fx.Annotate(
NewAnimeService,
fx.As(new(Service)),

View File

@@ -7,13 +7,14 @@ import (
)
type catalogRepoStub struct {
watchlist []db.GetUserWatchListRow
allContinueRows []db.GetContinueWatchingEntriesRow
carouselContinueRows []db.GetContinueWatchingEntriesRow
carouselLimit int64
}
func (r *catalogRepoStub) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
return nil, nil
return r.watchlist, nil
}
func (r *catalogRepoStub) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {

View File

@@ -104,6 +104,7 @@
</a>
<a href="/browse" class="hidden h-full items-center px-2 text-base transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground-muted sm:inline-flex sm:px-3 {{if eq $currentPath "/browse"}}text-foreground{{else}}text-foreground-muted{{end}}" aria-current="{{if eq $currentPath "/browse"}}page{{end}}">Browse</a>
<a href="/top-picks" class="hidden h-full items-center px-2 text-base transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground-muted sm:inline-flex sm:px-3 {{if eq $currentPath "/top-picks"}}text-foreground{{else}}text-foreground-muted{{end}}" aria-current="{{if eq $currentPath "/top-picks"}}page{{end}}">Top Picks</a>
<a href="/simulcast" class="hidden h-full items-center px-2 text-base transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground-muted sm:inline-flex sm:px-3 {{if eq $currentPath "/simulcast"}}text-foreground{{else}}text-foreground-muted{{end}}" aria-current="{{if eq $currentPath "/simulcast"}}page{{end}}">Simulcast</a>
</div>
<div class="hidden h-full items-center sm:flex">
@@ -126,6 +127,9 @@
<a href="/top-picks" class="flex items-center justify-between px-5 py-3 text-sm transition-colors hover:bg-surface-hover {{if eq $currentPath "/top-picks"}}text-foreground{{else}}text-foreground-muted hover:text-foreground{{end}}" aria-current="{{if eq $currentPath "/top-picks"}}page{{end}}">
<span>Top Picks</span>
</a>
<a href="/simulcast" class="flex items-center justify-between px-5 py-3 text-sm transition-colors hover:bg-surface-hover {{if eq $currentPath "/simulcast"}}text-foreground{{else}}text-foreground-muted hover:text-foreground{{end}}" aria-current="{{if eq $currentPath "/simulcast"}}page{{end}}">
<span>Simulcast</span>
</a>
<a href="/watchlist" class="flex items-center justify-between px-5 py-3 text-sm transition-colors hover:bg-surface-hover {{if eq $currentPath "/watchlist"}}text-foreground{{else}}text-foreground-muted hover:text-foreground{{end}}" aria-current="{{if eq $currentPath "/watchlist"}}page{{end}}">
<span>Watchlist</span>
</a>

View File

@@ -0,0 +1,58 @@
{{define "title"}}Simulcast{{end}}
{{define "content"}}
<div class="mx-auto flex w-full max-w-7xl flex-col gap-6 pb-12">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<h1 class="text-2xl font-medium text-foreground">Simulcast Season</h1>
<ui-dropdown class="relative block" data-align="right" data-width="min-w-44">
<div data-trigger class="cursor-pointer">
<button type="button" class="flex h-10 min-w-44 items-center justify-between gap-3 bg-background-button px-4 text-sm text-foreground transition-colors hover:bg-background-button-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent">
<span>{{.SeasonLabel}} {{.Year}}</span>
<svg class="h-4 w-4 text-foreground-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</div>
<div data-content class="absolute right-0 top-full z-50 mt-2 hidden max-h-80 min-w-44 overflow-y-auto bg-background-button shadow-card scrollbar-hidden">
<div class="flex flex-col py-1">
{{range .SeasonOptions}}
<a href="/simulcast?season={{.Season}}&year={{.Year}}" class="flex w-full items-center px-5 py-2.5 text-sm transition-colors hover:bg-surface-hover {{if and (eq .Season $.Season) (eq .Year $.Year)}}text-accent{{else}}text-foreground-muted hover:text-foreground{{end}}">{{.Label}}</a>
{{end}}
</div>
</div>
</ui-dropdown>
</div>
{{if .Animes}}
<div class="grid grid-cols-2 gap-x-4 gap-y-7 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{{range .Animes}}
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{end}}
</div>
{{else}}
<div class="border border-border-light px-6 py-16 text-center text-sm text-foreground-muted">No provider releases found for {{.SeasonLabel}} {{.Year}}.</div>
{{end}}
<div class="flex items-center justify-between border-t-2 border-border-light pt-4 text-xs font-medium uppercase tracking-wide text-foreground-muted">
{{if .Previous}}
<a href="/simulcast?season={{.Previous.Season}}&year={{.Previous.Year}}" class="inline-flex items-center gap-2 transition-colors hover:text-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
Previous season
</a>
{{else}}
<span class="inline-flex cursor-not-allowed items-center gap-2 opacity-35" aria-disabled="true">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
Previous season
</span>
{{end}}
{{if .Next}}
<a href="/simulcast?season={{.Next.Season}}&year={{.Next.Year}}" class="inline-flex items-center gap-2 transition-colors hover:text-foreground">
Next season
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</a>
{{else}}
<span class="inline-flex cursor-not-allowed items-center gap-2 opacity-35" aria-disabled="true">
Next season
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</span>
{{end}}
</div>
</div>
{{end}}