refactor: reorganize project structure following go standards
This commit is contained in:
@@ -1,337 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
import "strings"
|
||||
|
||||
templ AnimeDetails(anime jikan.Anime, currentStatus string, nextEpisode int) {
|
||||
@Layout("mal - " + anime.DisplayTitle(), true) {
|
||||
<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 gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="w-56">
|
||||
if anime.ImageURL() != "" {
|
||||
<img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
|
||||
} else {
|
||||
<div class="flex aspect-2/3 max-h-(--poster-max-height) w-full justify-center overflow-hidden text-transparent">No image</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h1>{ anime.DisplayTitle() }</h1>
|
||||
if anime.TitleJapanese != "" {
|
||||
<p class="my-2 mb-3 text-sm text-(--text-muted)">{ anime.TitleJapanese }</p>
|
||||
}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if anime.ShortRating() != "" {
|
||||
<span class="text-xs text-(--text-faint)">{ anime.ShortRating() }</span>
|
||||
}
|
||||
if anime.Type != "" {
|
||||
<span class="text-xs text-(--text-faint)">{ anime.Type }</span>
|
||||
}
|
||||
if anime.Episodes > 0 {
|
||||
<span class="text-xs text-(--text-faint)">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
|
||||
}
|
||||
if anime.ShortDuration() != "" {
|
||||
<span class="text-xs text-(--text-faint)">{ anime.ShortDuration() }</span>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
|
||||
<a
|
||||
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"
|
||||
>Watch</a>
|
||||
</div>
|
||||
</div>
|
||||
<section class="mt-4 max-w-4xl">
|
||||
if anime.Synopsis != "" {
|
||||
<p>{ anime.Synopsis }</p>
|
||||
} else {
|
||||
<p class="text-(--text-faint)">No synopsis available.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Related</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading relations")
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Recommendations</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading recommendations")
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="sticky top-20 grid gap-4 bg-(--panel) p-3 max-xl:static">
|
||||
<div class="grid gap-3">
|
||||
<h3 class="mb-2 text-base font-semibold tracking-wide text-(--text)">Details</h3>
|
||||
if anime.Aired.String != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Aired</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Aired.String }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Premiered() != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Premiered</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Premiered() }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Status != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Status</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Status }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Duration != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Duration</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Duration }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Genres) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Genres</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Genres) }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if hasExtraSidebarDetails(anime) {
|
||||
<details class="grid gap-3">
|
||||
<summary class="cursor-pointer text-xs text-(--text-muted)">More metadata</summary>
|
||||
if anime.TitleJapanese != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Japanese</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.TitleJapanese }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.TitleSynonyms) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Synonyms</span>
|
||||
<span class="text-sm text-(--text-muted)">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Studios) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Studios</span>
|
||||
<span class="text-sm text-(--text-muted)">
|
||||
@studioLinks(anime.Studios)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Producers) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Producers</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Producers) }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Source != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Source</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Source }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Demographics) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Demographics</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Demographics) }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Themes) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Themes</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Themes) }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Broadcast.String != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Broadcast</span>
|
||||
<span class="text-sm text-(--text-muted)" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Streaming) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Streaming</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinStreamingNames(anime) }</span>
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
func watchTargetEpisode(currentStatus string, nextEpisode int) int {
|
||||
if currentStatus == "watching" && nextEpisode > 0 {
|
||||
return nextEpisode
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
templ AnimePending(id int) {
|
||||
@Layout("mal - anime pending", true) {
|
||||
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="grid min-w-0 gap-8">
|
||||
<section>
|
||||
<h1>Anime data is being fetched</h1>
|
||||
<p class="text-sm text-(--text-muted)">We could not load this anime right now. A background worker is retrying data fetch for anime #{ fmt.Sprintf("%d", id) }.</p>
|
||||
<p class="text-sm text-(--text-muted)">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 {
|
||||
names := make([]string, len(entities))
|
||||
for i, e := range entities {
|
||||
names[i] = e.Name
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func joinStreamingNames(anime jikan.Anime) string {
|
||||
names := make([]string, len(anime.Streaming))
|
||||
for i, s := range anime.Streaming {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) {
|
||||
<div class="relative inline-block" id="watchlist-dropdown">
|
||||
<button class="inline-flex h-8 cursor-pointer items-center gap-2 bg-(--panel-soft) px-2 text-xs text-(--text)" onclick="toggleDropdown()" data-dropdown-trigger>
|
||||
if currentStatus != "" {
|
||||
{ formatStatus(currentStatus) }
|
||||
} else {
|
||||
Add to watchlist
|
||||
}
|
||||
<span class="text-xs">▾</span>
|
||||
</button>
|
||||
<div class="invisible absolute left-0 top-full mt-0.5 z-50 min-w-52 bg-(--panel) opacity-0 transition-opacity duration-150" data-dropdown-menu data-dropdown-open-classes="visible opacity-100" data-dropdown-closed-classes="invisible opacity-0">
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "dropped", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing)
|
||||
if currentStatus != "" {
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-between bg-transparent px-2.5 py-2 text-left text-xs text-(--text-muted) hover:bg-(--panel-soft) hover:text-(--danger)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
hx-swap="outerHTML swap:150ms"
|
||||
>Remove from list</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, status string, currentStatus string, airing bool) {
|
||||
<button
|
||||
class={
|
||||
"flex w-full cursor-pointer items-center justify-between bg-transparent px-2.5 py-2 text-left text-xs text-(--text-muted) hover:bg-(--panel-soft) hover:text-(--text)",
|
||||
templ.KV("bg-(--panel-soft) text-(--text)", status == currentStatus),
|
||||
}
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
hx-swap="outerHTML swap:150ms"
|
||||
>
|
||||
{ formatStatus(status) }
|
||||
</button>
|
||||
}
|
||||
|
||||
func formatStatus(status string) string {
|
||||
switch status {
|
||||
case "watching":
|
||||
return "Watching"
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "on_hold":
|
||||
return "On hold"
|
||||
case "dropped":
|
||||
return "Dropped"
|
||||
case "plan_to_watch":
|
||||
return "Plan to watch"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
||||
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-5" id="relations-grid">
|
||||
for _, rel := range relations {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: rel.Anime.MalID,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
ImageURL: rel.Anime.ImageURL(),
|
||||
Class: relationCardClass(rel),
|
||||
ImgClass: "relation-thumb",
|
||||
TitleClass: "relation-title",
|
||||
CurrentNode: rel.IsCurrent,
|
||||
}) {
|
||||
if rel.IsCurrent {
|
||||
<div class="mt-2 h-0.5 w-10 bg-white"></div>
|
||||
}
|
||||
if rel.Relation != "" && rel.Relation != "Current" {
|
||||
<div class="mt-1 text-xs text-(--text-faint)">{ rel.Relation }</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-(--text-muted)">No related anime found.</p>
|
||||
}
|
||||
}
|
||||
|
||||
func relationCardClass(rel jikan.RelationEntry) string {
|
||||
return "relation-card min-w-0 flex flex-col bg-transparent text-inherit no-underline"
|
||||
}
|
||||
|
||||
templ AnimeRecommendations(recs []jikan.Anime) {
|
||||
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-5">
|
||||
for _, anime := range recs {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
Class: "relation-card",
|
||||
ImgClass: "relation-thumb",
|
||||
TitleClass: "relation-title",
|
||||
})
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-(--text-muted)">No recommendations available.</p>
|
||||
}
|
||||
}
|
||||
|
||||
func hasExtraSidebarDetails(anime jikan.Anime) bool {
|
||||
return anime.TitleJapanese != "" || len(anime.TitleSynonyms) > 0 || len(anime.Studios) > 0 || len(anime.Producers) > 0 || anime.Source != "" || len(anime.Demographics) > 0 || len(anime.Themes) > 0 || anime.Broadcast.String != "" || len(anime.Streaming) > 0
|
||||
}
|
||||
|
||||
templ studioLinks(studios []jikan.NamedEntity) {
|
||||
for i, studio := range studios {
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/studios/%d", studio.MalID)) }
|
||||
class="hover:text-(--text) hover:underline"
|
||||
>{ studio.Name }</a>
|
||||
if i < len(studios)-1 {
|
||||
<span>, </span>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package templates
|
||||
|
||||
templ Login(formError string, username string) {
|
||||
@Layout("Login", false) {
|
||||
<div class="w-full max-w-xl">
|
||||
<div class="mx-auto w-full bg-(--panel) p-6">
|
||||
<h2 class="m-0 text-2xl">Sign in</h2>
|
||||
<p class="my-3 mb-5 text-sm text-(--text-muted)">Enter your credentials to continue.</p>
|
||||
<form action="/login" method="POST" class="grid gap-4">
|
||||
<div class="grid gap-1">
|
||||
<label for="username">Username / Email</label>
|
||||
<input class="h-10 border border-transparent bg-(--surface-search) px-3 text-(--text) transition-colors duration-120 focus:border-(--surface-search-focus-border) focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<label for="password">Password</label>
|
||||
<input class="h-10 border border-transparent bg-(--surface-search) px-3 text-(--text) transition-colors duration-120 focus:border-(--surface-search-focus-border) focus:outline-none" type="password" id="password" name="password" required placeholder="Your password"/>
|
||||
</div>
|
||||
<button type="submit" class="h-10 cursor-pointer border-0 bg-(--accent) text-sm font-semibold text-(--text-on-accent) hover:brightness-95">Sign in</button>
|
||||
if formError != "" {
|
||||
<p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ formError }</p>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
|
||||
templ Catalog() {
|
||||
@Layout("mal - catalog", true) {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="catalog-content">
|
||||
<div class="col-span-full" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
|
||||
@ui.LoadingIndicator("Loading catalog")
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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 CatalogPlaceholderItems(count int) {
|
||||
for i := 0; i < count; i++ {
|
||||
<div class="pointer-events-none min-w-0" aria-hidden="true">
|
||||
<div class="aspect-2/3 max-h-(--poster-max-height) w-full animate-pulse bg-(--surface-search)"></div>
|
||||
<div class="mt-2 h-4 w-4/5 animate-pulse bg-(--surface-search)"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/database"
|
||||
"mal/internal/shared/ui"
|
||||
)
|
||||
|
||||
templ ContinueWatching(entries []database.GetContinueWatchingEntriesRow) {
|
||||
@Layout("mal - continue watching", true) {
|
||||
<div class="grid gap-4">
|
||||
<h1>Continue watching</h1>
|
||||
<p class="m-0 text-sm text-(--text-muted)">Pick up where you left off.</p>
|
||||
if len(entries) == 0 {
|
||||
@ui.EmptyState("Nothing to continue yet") {
|
||||
Start watching any anime and your progress will show up here.
|
||||
}
|
||||
} else {
|
||||
<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 {
|
||||
<div class="group relative min-w-0" id={ fmt.Sprintf("continue-entry-%d", entry.AnimeID) }>
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: int(entry.AnimeID),
|
||||
Title: displayContinueWatchingTitle(entry),
|
||||
ImageURL: entry.ImageUrl,
|
||||
Href: continueWatchingURL(entry),
|
||||
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
|
||||
HideTitle: true,
|
||||
}) {
|
||||
<div class="mt-2 grid gap-1 p-0">
|
||||
<div class="line-clamp-2 text-sm leading-snug text-(--text)">{ displayContinueWatchingTitle(entry) }</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
<span class="text-xs text-(--text-faint)">Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }</span>
|
||||
}
|
||||
if entry.CurrentTimeSeconds > 0 {
|
||||
<span class="text-xs text-(--text-faint)">{ formatProgressTime(entry.CurrentTimeSeconds) }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<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)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/continue-watching/%d", entry.AnimeID))) }
|
||||
hx-target={ fmt.Sprintf("#continue-entry-%d", entry.AnimeID) }
|
||||
hx-swap="delete"
|
||||
>×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
func continueWatchingURL(entry database.GetContinueWatchingEntriesRow) 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 displayContinueWatchingTitle(entry database.GetContinueWatchingEntriesRow) string {
|
||||
return database.DisplayTitle(entry.TitleEnglish, entry.TitleJapanese, entry.TitleOriginal)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
|
||||
templ Discover() {
|
||||
@Layout("mal - discover", true) {
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-4">
|
||||
<h1>Discover</h1>
|
||||
<p class="m-0 text-sm text-(--text-muted)">Browse what's airing now and what is coming soon.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1" data-tab-group="discover">
|
||||
<button
|
||||
class="tab-trigger shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent)"
|
||||
type="button"
|
||||
hx-get="/api/discover/airing?page=1"
|
||||
hx-target="#discover-content"
|
||||
hx-trigger="click"
|
||||
data-tab-trigger
|
||||
data-tab-active-classes="bg-(--surface-tab-active) text-(--accent)"
|
||||
data-tab-inactive-classes="bg-(--panel-soft) text-(--text-muted)"
|
||||
>
|
||||
airing now
|
||||
</button>
|
||||
<button
|
||||
class="tab-trigger shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) hover:bg-(--surface-tab-hover) hover:text-(--text)"
|
||||
type="button"
|
||||
hx-get="/api/discover/upcoming?page=1"
|
||||
hx-target="#discover-content"
|
||||
hx-trigger="click"
|
||||
data-tab-trigger
|
||||
data-tab-active-classes="bg-(--surface-tab-active) text-(--accent)"
|
||||
data-tab-inactive-classes="bg-(--panel-soft) text-(--text-muted)"
|
||||
>
|
||||
upcoming
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
|
||||
<div class="col-span-full">
|
||||
@ui.LoadingIndicator("Loading discover")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/ui"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
templ Search(q string) {
|
||||
@Layout("mal - search", true) {
|
||||
if q != "" {
|
||||
<div id="loading" class="hidden htmx-request:inline-flex">
|
||||
@ui.LoadingIndicator("Searching...")
|
||||
</div>
|
||||
<div id="results" hx-get={ string(templ.URL("/search?q=" + url.QueryEscape(q))) } hx-trigger="load" hx-indicator="#loading"></div>
|
||||
} else {
|
||||
@ui.EmptyState("Search for anime") {
|
||||
Use the search bar above to find anime to add to your watchlist.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ SearchResultsWrapper(query string, animes []jikan.Anime, 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)
|
||||
</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")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/shared/ui/icons"
|
||||
|
||||
templ Layout(title string, showHeader bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
<link rel="stylesheet" href="/dist/tailwind.css"/>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
|
||||
<script src="/dist/discover.js" defer></script>
|
||||
<script src="/dist/anime.js" defer></script>
|
||||
<script src="/dist/timezone.js" defer></script>
|
||||
<script src="/dist/player.js" defer></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-(--bg) text-(--text) font-(--font) text-sm leading-normal">
|
||||
if showHeader {
|
||||
<header class="sticky top-0 z-100 bg-(--header)">
|
||||
<div class="mx-auto flex w-full max-w-screen-2xl items-center gap-4 px-4 py-3 max-lg:flex-wrap max-lg:gap-3">
|
||||
<div class="flex min-w-0 items-center gap-5 max-lg:w-full max-lg:flex-wrap max-lg:gap-3" data-search-root>
|
||||
<a href="/" class="inline-flex items-center text-(--accent)" aria-label="mal logo">
|
||||
@icons.LogoIcon("h-7 w-7")
|
||||
</a>
|
||||
<div class="flex flex-wrap gap-3 text-sm max-lg:w-full max-lg:gap-2">
|
||||
<a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/discover">Discover</a>
|
||||
<a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/continue-watching">Continue watching</a>
|
||||
<a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/watchlist">Watchlist</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative ml-auto min-w-60 w-full max-w-md max-lg:ml-0" data-search-root>
|
||||
<form action="/search" method="GET" class="w-full" id="search-form">
|
||||
<input type="text" id="search-input" name="q" class="h-9 w-full border border-transparent bg-(--surface-search) px-3 text-(--text) transition-colors duration-120 placeholder:text-(--text-faint) focus:border-(--surface-search-focus-border) focus:outline-none" placeholder="Search anime..." autocomplete="off"/>
|
||||
<div id="search-dropdown" class="absolute inset-x-0 top-full mt-0.5 z-50 max-h-screen overflow-y-auto bg-(--panel)" data-search-results-container></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
<main class={
|
||||
"mx-auto w-full max-w-screen-2xl px-4 pt-5 pb-8 max-lg:px-3 max-lg:pb-6",
|
||||
templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader),
|
||||
}>
|
||||
{ children... }
|
||||
</main>
|
||||
<script src="/dist/search.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package templates
|
||||
|
||||
templ NotFoundPage() {
|
||||
@Layout("mal - not found", false) {
|
||||
<section class="w-full max-w-3xl min-h-dvh mx-auto grid content-center justify-items-center gap-3 px-7 py-8 text-center">
|
||||
<p class="m-0 text-6xl leading-none tracking-wider text-(--text-muted) sm:text-7xl md:text-8xl lg:text-9xl">404</p>
|
||||
<h1 class="m-0 text-3xl sm:text-4xl md:text-5xl">Page not found</h1>
|
||||
<p class="text-(--text-muted)">The page you requested does not exist, or it was moved.</p>
|
||||
<p><a href="/" class="text-base text-(--accent) no-underline hover:underline">Back to catalog</a></p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
|
||||
templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, hasNext bool, nextPage int) {
|
||||
@Layout("mal - "+getProducerName(producer), true) {
|
||||
<div class="grid gap-5">
|
||||
<div class="grid gap-4 bg-(--panel) p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
if producer.Data.Images.Jpg.ImageURL != "" {
|
||||
<img
|
||||
src={ producer.Data.Images.Jpg.ImageURL }
|
||||
alt={ getProducerName(producer) }
|
||||
class="h-24 w-24 object-contain"
|
||||
/>
|
||||
}
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">{ getProducerName(producer) }</h1>
|
||||
if producer.Data.Established != "" {
|
||||
<p class="mt-1 text-sm text-(--text-muted)">
|
||||
Established: { formatEstablishedDate(producer.Data.Established) }
|
||||
</p>
|
||||
}
|
||||
if producer.Data.Count > 0 {
|
||||
<p class="text-sm text-(--text-faint)">
|
||||
{ fmt.Sprintf("%d anime", producer.Data.Count) }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if producer.Data.About != "" {
|
||||
<p class="text-sm text-(--text-muted) line-clamp-3">{ producer.Data.About }</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-semibold">Anime</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="studio-anime-content">
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
})
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
@StudioLoadMore(producer.Data.MalID, nextPage)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ StudioLoadMore(studioID int, nextPage int) {
|
||||
<div
|
||||
class="col-span-full h-px w-full"
|
||||
hx-get={ string(templ.URL(fmt.Sprintf("/api/studios/%d/anime?page=%d", studioID, nextPage))) }
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
}
|
||||
|
||||
templ StudioAnimeItems(animes []jikan.Anime, hasNext bool, studioID int, nextPage int) {
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
})
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
@StudioLoadMore(studioID, nextPage)
|
||||
}
|
||||
<script data-container="studio-anime-content">
|
||||
(function() {
|
||||
const scripts = document.querySelectorAll('script[data-container]');
|
||||
const currentScript = scripts[scripts.length - 1];
|
||||
const actualID = currentScript.getAttribute('data-container');
|
||||
const container = document.getElementById(actualID) || document;
|
||||
const items = container.querySelectorAll('[data-id]');
|
||||
const seen = new Set();
|
||||
items.forEach(item => {
|
||||
const id = item.getAttribute('data-id');
|
||||
if (id) {
|
||||
if (seen.has(id)) {
|
||||
item.remove();
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
func getProducerName(producer jikan.ProducerResponse) string {
|
||||
for _, title := range producer.Data.Titles {
|
||||
if title.Type == "Default" {
|
||||
return title.Title
|
||||
}
|
||||
}
|
||||
return "Studio"
|
||||
}
|
||||
|
||||
func formatEstablishedDate(date string) string {
|
||||
if len(date) >= 10 {
|
||||
return date[:10]
|
||||
}
|
||||
return date
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/ui"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// WatchPageData holds the data needed for the watch page
|
||||
type WatchPageData struct {
|
||||
MalID int
|
||||
Title string
|
||||
TitleEnglish string
|
||||
TitleJapanese string
|
||||
ImageURL string
|
||||
Airing bool
|
||||
CurrentEpisode string
|
||||
TotalEpisodes int
|
||||
StartTimeSeconds float64
|
||||
CurrentStatus string
|
||||
InitialMode string
|
||||
AvailableModes []string
|
||||
ModeSources map[string]ModeSource
|
||||
Segments []SkipSegment
|
||||
}
|
||||
|
||||
// ModeSource represents a stream source for a specific mode (dub/sub)
|
||||
type ModeSource struct {
|
||||
Token string `json:"token"`
|
||||
Subtitles []SubtitleItem `json:"subtitles"`
|
||||
}
|
||||
|
||||
// SubtitleItem represents a subtitle track
|
||||
type SubtitleItem struct {
|
||||
Lang string `json:"lang"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// SkipSegment represents a skippable segment (intro/outro)
|
||||
type SkipSegment struct {
|
||||
Type string `json:"type"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
templ WatchPage(anime jikan.Anime, data WatchPageData) {
|
||||
@Layout(fmt.Sprintf("%s - episode %s", anime.DisplayTitle(), data.CurrentEpisode), true) {
|
||||
<div class="w-full overflow-x-clip">
|
||||
<div class="mx-auto grid w-full gap-4 lg:gap-5 lg:grid-cols-[220px_minmax(0,1fr)_250px] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<!-- Left sidebar: Episodes -->
|
||||
<aside class="order-2 w-full min-w-0 lg:order-1">
|
||||
<div class="flex h-full max-h-[320px] flex-col sm:max-h-[420px] lg:max-h-[800px]">
|
||||
<div class="p-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-(--text)">Episodes</h3>
|
||||
</div>
|
||||
<div
|
||||
hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes?current=%s", anime.MalID, data.CurrentEpisode))) }
|
||||
hx-trigger="load"
|
||||
class="overflow-y-auto flex-1 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
@LoadingIndicatorSmall()
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content: Video and Controls -->
|
||||
<div class="order-1 flex min-w-0 flex-1 flex-col gap-4 sm:gap-5 lg:order-2">
|
||||
@VideoPlayer(data)
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 sm:justify-end">
|
||||
if canGoPrevEpisode(data.CurrentEpisode) {
|
||||
<a
|
||||
href={ templ.URL(episodeWithOffsetURL(anime.MalID, data.CurrentEpisode, -1)) }
|
||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) hover:no-underline"
|
||||
>
|
||||
◀ Prev
|
||||
</a>
|
||||
} else {
|
||||
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">◀ Prev</span>
|
||||
}
|
||||
if canGoNextEpisode(data.CurrentEpisode, anime.Episodes) {
|
||||
<a
|
||||
href={ templ.URL(episodeWithOffsetURL(anime.MalID, data.CurrentEpisode, 1)) }
|
||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) hover:no-underline"
|
||||
>
|
||||
Next ▶
|
||||
</a>
|
||||
} else {
|
||||
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">Next ▶</span>
|
||||
}
|
||||
<span id="watch-status-dropdown">
|
||||
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), data.CurrentStatus, anime.Airing)
|
||||
</span>
|
||||
</div>
|
||||
<section>
|
||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Watch more seasons of this anime</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading relations")
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right sidebar: Anime Info -->
|
||||
<aside class="order-3 w-full min-w-0 flex flex-col gap-4 lg:order-3">
|
||||
<img src={ anime.Images.Webp.LargeImageURL } alt={ anime.Title } class="mx-auto w-full max-w-sm object-cover shadow-lg lg:max-w-none" />
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-(--text)">{ anime.DisplayTitle() }</h2>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-(--text-muted)">
|
||||
if anime.ShortRating() != "" {
|
||||
<span>{ anime.ShortRating() }</span>
|
||||
<span>•</span>
|
||||
}
|
||||
<span>HD</span>
|
||||
<span>•</span>
|
||||
<span>{ anime.Type }</span>
|
||||
<span>•</span>
|
||||
if anime.ShortDuration() != "" {
|
||||
<span>{ anime.ShortDuration() }</span>
|
||||
} else {
|
||||
<span>{ anime.Duration }</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-(--text-muted) mt-4 line-clamp-6">
|
||||
{ anime.Synopsis }
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) }
|
||||
class="inline-flex h-9 items-center justify-center bg-(--panel-soft) px-4 text-sm font-semibold text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) transition-colors"
|
||||
>
|
||||
View detail
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ LoadingIndicatorSmall() {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="h-5 w-5 animate-spin border-2 border-(--panel-soft) border-t-(--accent)"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EpisodeList(episodes []jikan.Episode, currentEpisode string, animeID int) {
|
||||
if len(episodes) == 0 {
|
||||
<p class="py-4 text-center text-sm text-(--text-muted)">No episodes available</p>
|
||||
} else {
|
||||
<div class="flex flex-col">
|
||||
for _, ep := range episodes {
|
||||
@EpisodeItem(ep, currentEpisode, animeID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ EpisodeItem(episode jikan.Episode, currentEpisode string, animeID int) {
|
||||
{{ isCurrent := fmt.Sprintf("%d", episode.MalID) == currentEpisode }}
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, episode.MalID)) }
|
||||
class={
|
||||
"flex items-center gap-3 px-3 py-2.5 text-sm no-underline transition-colors border-b border-(--panel-soft) last:border-0",
|
||||
templ.KV("bg-white/5 text-white", isCurrent),
|
||||
templ.KV("text-(--text-muted) hover:bg-white/5 hover:text-(--text)", !isCurrent),
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={
|
||||
"flex shrink-0 items-center justify-center font-medium w-6",
|
||||
templ.KV("text-(--text)", isCurrent),
|
||||
templ.KV("text-(--text-faint)", !isCurrent),
|
||||
}
|
||||
>
|
||||
{ fmt.Sprintf("%d", episode.MalID) }
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
if episode.Title != "" {
|
||||
{ episode.Title }
|
||||
} else {
|
||||
Episode { fmt.Sprintf("%d", episode.MalID) }
|
||||
}
|
||||
</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
if episode.Filler {
|
||||
<span class="shrink-0 px-1.5 py-0.5 text-[9px] uppercase tracking-wider bg-yellow-900/50 text-yellow-400">Filler</span>
|
||||
}
|
||||
if episode.Recap {
|
||||
<span class="shrink-0 px-1.5 py-0.5 text-[9px] uppercase tracking-wider bg-blue-900/50 text-blue-400">Recap</span>
|
||||
}
|
||||
if isCurrent {
|
||||
<svg class="h-4 w-4 shrink-0 text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
templ VideoPlayer(data WatchPageData) {
|
||||
{{ streamToken := modeToken(data.InitialMode, data.ModeSources) }}
|
||||
{{ hasDub := modeAvailable(data.AvailableModes, "dub") }}
|
||||
{{ hasSub := modeAvailable(data.AvailableModes, "sub") }}
|
||||
<div
|
||||
class="flex w-full flex-col gap-4"
|
||||
data-mal-id={ fmt.Sprintf("%d", data.MalID) }
|
||||
data-total-episodes={ fmt.Sprintf("%d", data.TotalEpisodes) }
|
||||
data-video-player
|
||||
data-stream-url="/watch/proxy/stream"
|
||||
data-current-episode={ data.CurrentEpisode }
|
||||
data-anime-title={ data.Title }
|
||||
data-anime-title-english={ data.TitleEnglish }
|
||||
data-anime-title-japanese={ data.TitleJapanese }
|
||||
data-anime-image={ data.ImageURL }
|
||||
data-anime-airing={ fmt.Sprintf("%v", data.Airing) }
|
||||
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
|
||||
data-initial-mode={ data.InitialMode }
|
||||
data-stream-token={ streamToken }
|
||||
data-available-modes={ toJSON(data.AvailableModes) }
|
||||
data-mode-sources={ toJSON(data.ModeSources) }
|
||||
data-segments={ toJSON(data.Segments) }
|
||||
>
|
||||
<div class="group relative aspect-video w-full overflow-hidden bg-black">
|
||||
<video
|
||||
class="h-full w-full"
|
||||
preload="metadata"
|
||||
crossorigin="anonymous"
|
||||
playsinline
|
||||
src={ buildStreamURL(data.InitialMode, streamToken) }
|
||||
></video>
|
||||
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="h-8 w-8 animate-spin border-2 border-(--panel-soft) border-t-(--accent)"></div>
|
||||
</div>
|
||||
<div data-subtitle-text class="absolute bottom-16 left-1/2 z-20 hidden max-w-[92vw] -translate-x-1/2 px-3 text-center text-sm font-semibold text-white drop-shadow-lg sm:bottom-20 sm:max-w-[88vw] sm:px-4 sm:text-lg md:text-xl"></div>
|
||||
<button data-skip class="absolute bottom-20 right-3 z-20 hidden border border-white bg-transparent px-3 py-1.5 text-sm font-semibold text-white transition-opacity hover:opacity-85 sm:bottom-24 sm:right-5 sm:px-4 sm:py-2 sm:text-base">
|
||||
Skip intro
|
||||
</button>
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-3 pb-3 pt-10 opacity-100 transition-opacity sm:px-4 sm:pb-4 sm:pt-12 sm:opacity-0 sm:group-hover:opacity-100 sm:group-[.show-controls]:opacity-100 group-[.show-controls]:opacity-100">
|
||||
<div data-progress-wrap class="group/progress relative mb-3 h-1 cursor-pointer bg-white/30 sm:mb-5">
|
||||
<div data-preview-popover class="pointer-events-none absolute bottom-[calc(100%+10px)] left-0 z-40 hidden -translate-x-1/2">
|
||||
<div class="overflow-hidden border border-white/20 bg-black shadow-xl">
|
||||
<div data-preview-time class="bg-white px-2 py-1 text-center text-xs font-semibold text-black tabular-nums">00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-segments class="pointer-events-none absolute inset-0 z-20"></div>
|
||||
<div data-progress class="pointer-events-none absolute inset-y-0 left-0 z-10 bg-blue-500"></div>
|
||||
<div data-scrubber class="pointer-events-none absolute -top-1.5 z-30 h-5 w-5 rounded-full -translate-x-1/2 bg-white opacity-0 transition-opacity group-hover/progress:opacity-100" style="left: 0%"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:flex-nowrap sm:gap-4">
|
||||
<div class="flex min-w-0 items-center gap-2 sm:gap-4">
|
||||
<button data-play-pause data-state="paused" class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="Play">
|
||||
<svg data-icon-play class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="8 5 19 12 8 19" fill="white" stroke="none"></polygon>
|
||||
</svg>
|
||||
<svg data-icon-pause class="hidden h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="9" y1="6" x2="9" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="15" y1="6" x2="15" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div data-volume-wrap class="relative flex h-9 w-9 items-center justify-center overflow-visible sm:h-10 sm:w-10">
|
||||
<span data-volume-bridge class="volume-bridge-off absolute inset-x-0 bottom-full h-[calc(100%+26px)]"></span>
|
||||
<div data-volume-panel class="volume-panel pointer-events-none absolute bottom-[calc(100%+26px)] left-1/2 z-30 -translate-x-1/2 opacity-0 invisible transition-opacity">
|
||||
<input
|
||||
data-volume-range
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="100"
|
||||
class="h-24 w-4 cursor-pointer appearance-none bg-transparent accent-white [&::-webkit-slider-runnable-track]:w-[4px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/55 [&::-webkit-slider-runnable-track]:[box-shadow:0_0_10px_rgba(0,0,0,0.45)] [&::-moz-range-track]:w-[4px] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-white/55 [&::-moz-range-track]:[box-shadow:0_0_10px_rgba(0,0,0,0.45)] [&::-webkit-slider-thumb]:h-[14px] [&::-webkit-slider-thumb]:w-[14px] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:-ml-[5px] [&::-webkit-slider-thumb]:[box-shadow:0_0_10px_rgba(0,0,0,0.55)] [&::-moz-range-thumb]:h-[14px] [&::-moz-range-thumb]:w-[14px] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:[box-shadow:0_0_10px_rgba(0,0,0,0.55)] [writing-mode:vertical-lr] [direction:rtl]"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
<button data-mute class="relative flex h-9 w-9 items-center justify-center pb-1 text-white sm:h-10 sm:w-10" aria-label="Mute">
|
||||
<svg data-icon-volume class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 9c1.3 1.3 1.3 4.7 0 6" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M18.8 6.5c3 2.9 3 8.1 0 11" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<svg data-icon-muted class="hidden h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="9" x2="20" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
<line x1="20" y1="9" x2="16" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="volume-underline pointer-events-none absolute bottom-0 left-1/2 h-0.5 w-6 -translate-x-1/2 bg-white opacity-0 transition-opacity"></span>
|
||||
</div>
|
||||
<span data-time class="min-w-0 text-sm text-white tabular-nums sm:text-base">00:00 / 00:00</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-1.5 sm:gap-3">
|
||||
<button
|
||||
data-mode-dub
|
||||
class={
|
||||
"flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10",
|
||||
templ.KV("opacity-50 cursor-not-allowed", !hasDub),
|
||||
}
|
||||
title={ modeButtonTitle("Dub", hasDub) }
|
||||
disabled?={ !hasDub }
|
||||
>
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 9h6M6 15h4M12 9v6M17 7.5c2.2 2 2.2 7 0 9M19.2 5.5c3.4 3.2 3.4 10 0 13" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
data-mode-sub
|
||||
class={
|
||||
"flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10",
|
||||
templ.KV("opacity-50 cursor-not-allowed", !hasSub),
|
||||
}
|
||||
title={ modeButtonTitle("Sub", hasSub) }
|
||||
disabled?={ !hasSub }
|
||||
>
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3.5" y="5.5" width="17" height="13" rx="2" stroke="white" stroke-width="1.85" fill="none"/>
|
||||
<path d="M8 11.5h8M8 14.5h5" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-backward class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="-10s">
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 50 50" aria-hidden="true">
|
||||
<path d="M29.9199 45H25.2051V26.5391L20.6064 28.3154V24.3975L29.4219 20.7949H29.9199V45ZM48.1013 35.0059C48.1013 38.3483 47.4926 40.9049 46.2751 42.6758C45.0687 44.4466 43.3422 45.332 41.0954 45.332C38.8708 45.332 37.1498 44.4743 35.9323 42.7588C34.726 41.0322 34.1006 38.5641 34.0564 35.3545V30.7891C34.0564 27.4577 34.6596 24.9121 35.8659 23.1523C37.0723 21.3815 38.8044 20.4961 41.0622 20.4961C43.32 20.4961 45.0521 21.3704 46.2585 23.1191C47.4649 24.8678 48.0792 27.3636 48.1013 30.6064V35.0059ZM43.3864 30.1084C43.3864 28.2048 43.1983 26.777 42.822 25.8252C42.4457 24.8734 41.8591 24.3975 41.0622 24.3975C39.5681 24.3975 38.7933 26.1406 38.738 29.627V35.6533C38.738 37.6012 38.9262 39.0511 39.3025 40.0029C39.6898 40.9548 40.2875 41.4307 41.0954 41.4307C41.8591 41.4307 42.4236 40.988 42.7888 40.1025C43.1651 39.2061 43.3643 37.8392 43.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M40.0106 5.45398V0L50 7.79529L40.0106 15.5914V10.3033H4.9114V40.1506H18.7558V45H2.01875e-06V5.45398H40.0106Z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-forward class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="+10s">
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 52 50" aria-hidden="true">
|
||||
<path d="M11.9199 45H7.20508V26.5391L2.60645 28.3154V24.3975L11.4219 20.7949H11.9199V45ZM30.1013 35.0059C30.1013 38.3483 29.4926 40.9049 28.2751 42.6758C27.0687 44.4466 25.3422 45.332 23.0954 45.332C20.8708 45.332 19.1498 44.4743 17.9323 42.7588C16.726 41.0322 16.1006 38.5641 16.0564 35.3545V30.7891C16.0564 27.4577 16.6596 24.9121 17.8659 23.1523C19.0723 21.3815 20.8044 20.4961 23.0622 20.4961C25.32 20.4961 27.0521 21.3704 28.2585 23.1191C29.4649 24.8678 30.0792 27.3636 30.1013 30.6064V35.0059ZM25.3864 30.1084C25.3864 28.2048 25.1983 26.777 24.822 25.8252C24.4457 24.8734 23.8591 24.3975 23.0622 24.3975C21.5681 24.3975 20.7933 26.1406 20.738 29.627V35.6533C20.738 37.6012 20.9262 39.0511 21.3025 40.0029C21.6898 40.9548 22.2875 41.4307 23.0954 41.4307C23.8591 41.4307 24.4236 40.988 24.7888 40.1025C25.1651 39.2061 25.3643 37.8392 25.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M11.9894 5.45398V0L2 7.79529L11.9894 15.5914V10.3033H47.0886V40.1506H33.2442V45H52V5.45398H11.9894Z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-fullscreen class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="Fullscreen">
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 240 240" aria-hidden="true">
|
||||
<path d="M96.3,186.1c1.9,1.9,1.3,4-1.4,4.4l-50.6,8.4c-1.8,0.5-3.7-0.6-4.2-2.4c-0.2-0.6-0.2-1.2,0-1.7l8.4-50.6c0.4-2.7,2.4-3.4,4.4-1.4l14.5,14.5l28.2-28.2l14.3,14.3l-28.2,28.2L96.3,186.1z M195.8,39.1l-50.6,8.4c-2.7,0.4-3.4,2.4-1.4,4.4l14.5,14.5l-28.2,28.2l14.3,14.3l28.2-28.2l14.5,14.5c1.9,1.9,4,1.3,4.4-1.4l8.4-50.6c0.5-1.8-0.6-3.6-2.4-4.2C197,39,196.4,39,195.8,39.1L195.8,39.1z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func buildStreamURL(mode string, token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/watch/proxy/stream?mode=%s&token=%s", url.QueryEscape(mode), url.QueryEscape(token))
|
||||
}
|
||||
|
||||
func modeToken(mode string, modeSources map[string]ModeSource) string {
|
||||
normalizedMode := mode
|
||||
if _, ok := modeSources[normalizedMode]; !ok {
|
||||
if _, ok := modeSources["dub"]; ok {
|
||||
normalizedMode = "dub"
|
||||
} else if _, ok := modeSources["sub"]; ok {
|
||||
normalizedMode = "sub"
|
||||
} else {
|
||||
for key := range modeSources {
|
||||
normalizedMode = key
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source, ok := modeSources[normalizedMode]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return source.Token
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func episodeWithOffsetURL(animeID int, currentEpisode string, offset int) string {
|
||||
episodeID, err := strconv.Atoi(currentEpisode)
|
||||
if err != nil {
|
||||
episodeID = 1
|
||||
}
|
||||
nextEpisode := episodeID + offset
|
||||
if nextEpisode < 1 {
|
||||
nextEpisode = 1
|
||||
}
|
||||
return fmt.Sprintf("/watch/%d/%d", animeID, nextEpisode)
|
||||
}
|
||||
|
||||
func canGoPrevEpisode(currentEpisode string) bool {
|
||||
episodeID, err := strconv.Atoi(currentEpisode)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return episodeID > 1
|
||||
}
|
||||
|
||||
func canGoNextEpisode(currentEpisode string, totalEpisodes int) bool {
|
||||
if totalEpisodes <= 0 {
|
||||
return true
|
||||
}
|
||||
episodeID, err := strconv.Atoi(currentEpisode)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return episodeID < totalEpisodes
|
||||
}
|
||||
|
||||
func modeAvailable(modes []string, mode string) bool {
|
||||
for _, value := range modes {
|
||||
if value == mode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func modeButtonTitle(label string, enabled bool) string {
|
||||
if enabled {
|
||||
return label
|
||||
}
|
||||
|
||||
return label + " unavailable for this episode"
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/database"
|
||||
"mal/internal/shared/ui"
|
||||
"math"
|
||||
)
|
||||
|
||||
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
|
||||
@Layout("mal - watchlist", true) {
|
||||
<div class="mb-4 flex items-end justify-between gap-4 max-lg:flex-col max-lg:items-start">
|
||||
<div class="grid gap-1">
|
||||
<h2>Watchlist</h2>
|
||||
<p class="m-0 text-sm text-(--text-muted)">Track what you're watching with less noise.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 max-lg:w-full max-lg:justify-start">
|
||||
<a href="/api/watchlist/export" class="inline-flex min-w-16 items-center justify-center px-2 py-1 text-center text-xs leading-tight text-(--text-muted) no-underline hover:text-(--accent) hover:no-underline">Export</a>
|
||||
<button class="inline-flex min-w-16 cursor-pointer items-center justify-center border-0 bg-transparent px-2 py-1 text-center text-xs leading-tight text-(--text-muted) hover:text-(--accent)" type="button" onclick="document.getElementById('import-file').click()">Import</button>
|
||||
<form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="hidden">
|
||||
<input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/>
|
||||
</form>
|
||||
<div class="flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
|
||||
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "grid") }>Grid</a>
|
||||
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
|
||||
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "on_hold", sortBy, sortOrder)) } class={ tabClass(currentStatus == "on_hold") }>On hold</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "plan_to_watch", sortBy, sortOrder)) } class={ tabClass(currentStatus == "plan_to_watch") }>Plan to watch</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "dropped", sortBy, sortOrder)) } class={ tabClass(currentStatus == "dropped") }>Dropped</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "completed", sortBy, sortOrder)) } class={ tabClass(currentStatus == "completed") }>Completed</a>
|
||||
</div>
|
||||
@ui.SortFilter(ui.SortFilterOptions{Sort: sortBy, Order: sortOrder, View: layout, Status: currentStatus})
|
||||
if len(entries) == 0 {
|
||||
@ui.EmptyState("Nothing here yet") {
|
||||
if currentStatus == "all" {
|
||||
Your watchlist is empty. <a href="/">Search for anime</a> to get started.
|
||||
} else {
|
||||
No anime in this category.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if layout == "grid" {
|
||||
<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 {
|
||||
<div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||
<a href={ templ.URL(watchURL(entry)) } class="flex flex-col bg-transparent text-inherit no-underline">
|
||||
<div class="flex w-full aspect-2/3 justify-center overflow-hidden">
|
||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="block w-full object-cover object-center" loading="lazy"/>
|
||||
</div>
|
||||
<div class="mt-2 line-clamp-2 text-sm leading-snug text-(--text)">
|
||||
{ entry.DisplayTitle() }
|
||||
</div>
|
||||
@ifHasProgress(entry)
|
||||
</a>
|
||||
<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)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
|
||||
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
|
||||
hx-swap="delete"
|
||||
>×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<table class="block w-full overflow-x-auto whitespace-nowrap bg-(--panel) md:table md:overflow-visible md:whitespace-normal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2.5"></th>
|
||||
<th class="p-2.5 text-left text-xs text-(--text-faint)">Title</th>
|
||||
<th class="p-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, entry := range entries {
|
||||
<tr class="hover:bg-(--panel-soft)" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||
<td class="p-2.5">
|
||||
<a href={ templ.URL(watchURL(entry)) }>
|
||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-2/3 w-9 object-cover" loading="lazy"/>
|
||||
</a>
|
||||
</td>
|
||||
<td class="p-2.5 font-medium">
|
||||
<a href={ templ.URL(watchURL(entry)) }>
|
||||
{ entry.DisplayTitle() }
|
||||
</a>
|
||||
@ifHasProgress(entry)
|
||||
</td>
|
||||
<td class="w-24 p-2.5">
|
||||
<button
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-xs text-(--text-muted) hover:text-(--danger)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
|
||||
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
|
||||
hx-swap="delete"
|
||||
>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ ifHasProgress(entry database.GetUserWatchListRow) {
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 && entry.Status != "completed" {
|
||||
<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 {
|
||||
return fmt.Sprintf("/anime/%d", entry.AnimeID)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return "shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent) no-underline hover:no-underline"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func watchlistURL(view string, status string, sortBy string, sortOrder string) string {
|
||||
return fmt.Sprintf("/watchlist?view=%s&status=%s&sort=%s&order=%s", view, status, sortBy, sortOrder)
|
||||
}
|
||||
Reference in New Issue
Block a user