feat: add resume links
This commit is contained in:
@@ -155,7 +155,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
userID := userIDFromRequest(r)
|
userID := userIDFromRequest(r)
|
||||||
|
|
||||||
anime, currentStatus, err := h.svc.GetAnimeDetails(r.Context(), id, userID)
|
anime, currentStatus, nextEpisode, err := h.svc.GetAnimeDetails(r.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrAnimePendingFetch) {
|
if errors.Is(err, ErrAnimePendingFetch) {
|
||||||
templates.AnimePending(id).Render(r.Context(), w)
|
templates.AnimePending(id).Render(r.Context(), w)
|
||||||
@@ -172,7 +172,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
templates.AnimeDetails(anime, currentStatus).Render(r.Context(), w)
|
templates.AnimeDetails(anime, currentStatus, nextEpisode).Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -54,22 +54,23 @@ func (s *Service) GetUpcomingAnime(ctx context.Context, page int) (jikan.TopAnim
|
|||||||
return s.jikanClient.GetSeasonsUpcoming(ctx, page)
|
return s.jikanClient.GetSeasonsUpcoming(ctx, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, int, 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) {
|
if jikan.IsNotFoundError(err) {
|
||||||
return jikan.Anime{}, "", err
|
return jikan.Anime{}, "", 1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.jikanClient.EnqueueAnimeFetchRetry(ctx, id, err)
|
s.jikanClient.EnqueueAnimeFetchRetry(ctx, id, err)
|
||||||
if jikan.IsRetryableError(err) {
|
if jikan.IsRetryableError(err) {
|
||||||
return jikan.Anime{}, "", ErrAnimePendingFetch
|
return jikan.Anime{}, "", 1, ErrAnimePendingFetch
|
||||||
}
|
}
|
||||||
|
|
||||||
return jikan.Anime{}, "", fmt.Errorf("failed to fetch anime details: %w", err)
|
return jikan.Anime{}, "", 1, fmt.Errorf("failed to fetch anime details: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStatus := ""
|
currentStatus := ""
|
||||||
|
nextEpisode := 1
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -77,10 +78,16 @@ func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (j
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
currentStatus = entry.Status
|
currentStatus = entry.Status
|
||||||
|
if entry.CurrentEpisode.Valid {
|
||||||
|
value := int(entry.CurrentEpisode.Int64)
|
||||||
|
if value > 0 {
|
||||||
|
nextEpisode = value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return anime, currentStatus, nil
|
return anime, currentStatus, nextEpisode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
func (s *Service) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "mal/internal/shared/ui"
|
|||||||
import "fmt"
|
import "fmt"
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
templ AnimeDetails(anime jikan.Anime, currentStatus string, nextEpisode int) {
|
||||||
@Layout("mal - " + anime.DisplayTitle(), true) {
|
@Layout("mal - " + anime.DisplayTitle(), true) {
|
||||||
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
<div class="grid min-w-0 gap-8">
|
<div class="grid min-w-0 gap-8">
|
||||||
@@ -40,7 +40,7 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
|
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
|
||||||
<a
|
<a
|
||||||
href={ templ.URL(fmt.Sprintf("/watch/%d/1", anime.MalID)) }
|
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, watchTargetEpisode(currentStatus, nextEpisode))) }
|
||||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:text-(--text) hover:no-underline"
|
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:text-(--text) hover:no-underline"
|
||||||
>Watch</a>
|
>Watch</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +167,14 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func watchTargetEpisode(currentStatus string, nextEpisode int) int {
|
||||||
|
if currentStatus == "watching" && nextEpisode > 0 {
|
||||||
|
return nextEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
templ AnimePending(id int) {
|
templ AnimePending(id int) {
|
||||||
@Layout("mal - anime pending", true) {
|
@Layout("mal - anime pending", true) {
|
||||||
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"mal/internal/database"
|
"mal/internal/database"
|
||||||
"mal/internal/shared/ui"
|
"mal/internal/shared/ui"
|
||||||
|
"math"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
|
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
|
||||||
@@ -50,11 +51,15 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
|||||||
<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">
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
<div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
<div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||||
@ui.AnimeCard(ui.AnimeCardProps{
|
<a href={ templ.URL(watchURL(entry)) } class="flex flex-col bg-transparent text-inherit no-underline">
|
||||||
ID: int(entry.AnimeID),
|
<div class="flex w-full aspect-2/3 justify-center overflow-hidden">
|
||||||
Title: entry.DisplayTitle(),
|
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="block w-full object-cover object-center" loading="lazy"/>
|
||||||
ImageURL: entry.ImageUrl,
|
</div>
|
||||||
})
|
<div class="mt-2 line-clamp-2 text-sm leading-snug text-(--text)">
|
||||||
|
{ entry.DisplayTitle() }
|
||||||
|
</div>
|
||||||
|
@ifHasProgress(entry)
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
class="absolute right-2 top-2 h-6 w-6 cursor-pointer border-0 bg-(--overlay-subtle) text-(--text-muted) opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-(--danger)"
|
class="absolute right-2 top-2 h-6 w-6 cursor-pointer border-0 bg-(--overlay-subtle) text-(--text-muted) opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-(--danger)"
|
||||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
|
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
|
||||||
@@ -77,14 +82,15 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
<tr class="hover:bg-(--panel-soft)" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
<tr class="hover:bg-(--panel-soft)" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||||
<td class="p-2.5">
|
<td class="p-2.5">
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
|
<a href={ templ.URL(watchURL(entry)) }>
|
||||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-2/3 w-9 object-cover" loading="lazy"/>
|
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-2/3 w-9 object-cover" loading="lazy"/>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-2.5 font-medium">
|
<td class="p-2.5 font-medium">
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
|
<a href={ templ.URL(watchURL(entry)) }>
|
||||||
{ entry.DisplayTitle() }
|
{ entry.DisplayTitle() }
|
||||||
</a>
|
</a>
|
||||||
|
@ifHasProgress(entry)
|
||||||
</td>
|
</td>
|
||||||
<td class="w-24 p-2.5">
|
<td class="w-24 p-2.5">
|
||||||
<button
|
<button
|
||||||
@@ -103,6 +109,37 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ ifHasProgress(entry database.GetUserWatchListRow) {
|
||||||
|
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||||
|
<p class="m-0 mt-1 text-xs text-(--text-faint)">
|
||||||
|
Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
|
||||||
|
if entry.CurrentTimeSeconds > 0 {
|
||||||
|
{ fmt.Sprintf(" · %s", formatProgressTime(entry.CurrentTimeSeconds)) }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchURL(entry database.GetUserWatchListRow) string {
|
||||||
|
episode := 1
|
||||||
|
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||||
|
episode = int(entry.CurrentEpisode.Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("/watch/%d/%d", entry.AnimeID, episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatProgressTime(seconds float64) string {
|
||||||
|
total := int(math.Round(seconds))
|
||||||
|
if total < 0 {
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := total / 60
|
||||||
|
remainingSeconds := total % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d", minutes, remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
func tabClass(active bool) string {
|
func tabClass(active bool) string {
|
||||||
base := "shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) no-underline hover:bg-(--surface-tab-hover) hover:text-(--text) hover:no-underline"
|
base := "shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) no-underline hover:bg-(--surface-tab-hover) hover:text-(--text) hover:no-underline"
|
||||||
if active {
|
if active {
|
||||||
|
|||||||
Reference in New Issue
Block a user