refactor: remove old templ-based web system
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ Pending(id int) {
|
||||
@layout.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(() => window.location.reload(), 10000)
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
ui "mal/web/components"
|
||||
)
|
||||
|
||||
templ Recommendations(recs []jikan.Anime, watchlistStatuses map[int]string) {
|
||||
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-6">
|
||||
for _, anime := range recs {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
TitleEnglish: anime.TitleEnglish,
|
||||
TitleJapanese: anime.TitleJapanese,
|
||||
Airing: anime.Airing,
|
||||
Synopsis: anime.Synopsis,
|
||||
WatchlistStatus: watchlistStatuses[anime.MalID],
|
||||
})
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-(--text-muted)">No recommendations available.</p>
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
ui "mal/web/components"
|
||||
)
|
||||
|
||||
templ RelationsList(relations []jikan.RelationEntry, watchlistStatuses map[int]string) {
|
||||
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-6" id="relations-grid">
|
||||
for _, rel := range relations {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: rel.Anime.MalID,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
ImageURL: rel.Anime.ImageURL(),
|
||||
TitleEnglish: rel.Anime.TitleEnglish,
|
||||
TitleJapanese: rel.Anime.TitleJapanese,
|
||||
Airing: rel.Anime.Airing,
|
||||
CurrentNode: rel.IsCurrent,
|
||||
WatchlistStatus: watchlistStatuses[rel.Anime.MalID],
|
||||
}) {
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
)
|
||||
|
||||
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,117 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mal/web/components/watchlist"
|
||||
)
|
||||
|
||||
type AnimeCardProps struct {
|
||||
ID int
|
||||
Title string
|
||||
ImageURL string
|
||||
Href string
|
||||
Class string
|
||||
ImgClass string
|
||||
TitleClass string
|
||||
HideTitle bool
|
||||
CurrentNode bool
|
||||
Synopsis string
|
||||
PlayHref string
|
||||
TitleEnglish string
|
||||
TitleJapanese string
|
||||
Airing bool
|
||||
WatchlistStatus string
|
||||
DisableWatchlist bool
|
||||
}
|
||||
|
||||
templ AnimeCard(props AnimeCardProps) {
|
||||
<div class={ defaultString(props.Class, "group min-w-0") }>
|
||||
@animeCardPoster(props)
|
||||
if !props.HideTitle {
|
||||
if props.CurrentNode {
|
||||
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-sm leading-snug text-(--text)") }>
|
||||
{ props.Title }
|
||||
</div>
|
||||
} else {
|
||||
<a href={ templ.URL(cardHref(props)) } class="block">
|
||||
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-sm leading-snug text-(--text)") }>
|
||||
{ props.Title }
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
{ children... }
|
||||
</div>
|
||||
}
|
||||
|
||||
func cardHref(props AnimeCardProps) string {
|
||||
if props.Href != "" {
|
||||
return props.Href
|
||||
}
|
||||
return fmt.Sprintf("/anime/%d", props.ID)
|
||||
}
|
||||
|
||||
templ animeCardPoster(props AnimeCardProps) {
|
||||
<div class="relative grid w-full aspect-2/3 overflow-hidden">
|
||||
<div class="col-start-1 row-start-1 h-full w-full">
|
||||
@animeCardImage(props)
|
||||
</div>
|
||||
<div class="pointer-events-none col-start-1 row-start-1 bg-black/0 transition-colors duration-200 group-hover:bg-black/40"></div>
|
||||
if props.Synopsis != "" {
|
||||
<div class="pointer-events-none col-start-1 row-start-1 flex flex-col justify-between p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div>
|
||||
<div class="mb-1 text-[11px] font-semibold text-white/90">{ props.Title }</div>
|
||||
<p class="line-clamp-3 text-[11px] leading-relaxed text-white/90">{ props.Synopsis }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if !props.CurrentNode {
|
||||
<a href={ templ.URL(cardHref(props)) } class="absolute inset-0 z-10" aria-label={ props.Title }></a>
|
||||
}
|
||||
if props.PlayHref != "" || !props.CurrentNode && !props.DisableWatchlist {
|
||||
<div class="pointer-events-none col-start-1 row-start-1 z-20 flex items-end justify-start p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div class="pointer-events-auto flex gap-2">
|
||||
if props.PlayHref != "" {
|
||||
<a
|
||||
href={ templ.URL(props.PlayHref) }
|
||||
class="text-white"
|
||||
aria-label="Play"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Play</title>
|
||||
<path d="M8 5V19L19 12L8 5Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
if !props.CurrentNode && !props.DisableWatchlist {
|
||||
@watchlist.CardButton(
|
||||
props.ID,
|
||||
props.Title,
|
||||
props.TitleEnglish,
|
||||
props.TitleJapanese,
|
||||
props.ImageURL,
|
||||
props.Airing,
|
||||
props.WatchlistStatus != "",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ animeCardImage(props AnimeCardProps) {
|
||||
if props.ImageURL != "" {
|
||||
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "block h-full w-full object-cover object-center") } loading="lazy"/>
|
||||
} else {
|
||||
<div class="flex h-full w-full justify-center overflow-hidden text-transparent">No image</div>
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(val, fallback string) string {
|
||||
if val == "" {
|
||||
return fallback
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
)
|
||||
|
||||
templ InfiniteAnimeList(animes []jikan.Anime, watchlistStatuses map[int]string, hasNext bool, nextURL string, containerID string) {
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@CatalogItem(anime, watchlistStatuses[anime.MalID])
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
<div class="col-span-full h-px w-full" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
|
||||
}
|
||||
<script src="/dist/static/dedupe.js" data-container={ containerID } defer></script>
|
||||
}
|
||||
|
||||
templ CatalogItem(anime jikan.Anime, watchlistStatus string) {
|
||||
@AnimeCard(AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
TitleEnglish: anime.TitleEnglish,
|
||||
TitleJapanese: anime.TitleJapanese,
|
||||
Airing: anime.Airing,
|
||||
Synopsis: anime.Synopsis,
|
||||
PlayHref: fmt.Sprintf("/watch/%d/1", anime.MalID),
|
||||
WatchlistStatus: watchlistStatus,
|
||||
})
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package ui
|
||||
|
||||
templ EmptyState(title string) {
|
||||
<div class="py-4">
|
||||
<div class="mb-2 text-base">{ title }</div>
|
||||
<div class="text-sm text-(--text-muted)">
|
||||
{ children... }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package icons
|
||||
|
||||
templ LogoIcon(class string) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class={ class } viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="miter">
|
||||
<!-- clean, superminimal, abstract logo without border-radius -->
|
||||
<rect x="6" y="10" width="12" height="12"></rect>
|
||||
<rect x="14" y="6" width="12" height="12"></rect>
|
||||
</svg>
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package ui
|
||||
|
||||
templ LoadingIndicator(text string) {
|
||||
<div class="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<div class="flex gap-1.5">
|
||||
<span class="h-2 w-2 rounded-full bg-(--text-faint) animate-bounce" style="animation-delay: 0ms;"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-(--text-faint) animate-bounce" style="animation-delay: 150ms;"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-(--text-faint) animate-bounce" style="animation-delay: 300ms;"></span>
|
||||
</div>
|
||||
<span class="text-xs uppercase tracking-widest text-(--text-faint)">{ text }</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package ui
|
||||
|
||||
type SortFilterOptions struct {
|
||||
Sort string // "title", "date"
|
||||
Order string // "asc", "desc"
|
||||
Status string // for watchlist: "all", "watching", etc
|
||||
}
|
||||
|
||||
templ SortFilter(opts SortFilterOptions) {
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3 bg-(--panel) p-3 max-lg:flex-col max-lg:items-start max-lg:gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="sort-select" class="text-xs text-(--text-muted)">Sort by</label>
|
||||
<select id="sort-select" class="h-8 bg-(--surface-select) px-2 text-xs text-(--text)">
|
||||
<option value="date" selected?={ opts.Sort == "date" }>Date added</option>
|
||||
<option value="title" selected?={ opts.Sort == "title" }>Title</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="order-select" class="text-xs text-(--text-muted)">Order</label>
|
||||
<select id="order-select" class="h-8 bg-(--surface-select) px-2 text-xs text-(--text)">
|
||||
<option value="desc" selected?={ opts.Order == "desc" }>Descending</option>
|
||||
<option value="asc" selected?={ opts.Order == "asc" }>Ascending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<form id="sort-form" method="get" class="hidden">
|
||||
<input type="hidden" name="sort" id="sort-input" value={ opts.Sort }/>
|
||||
<input type="hidden" name="order" id="order-input" value={ opts.Order }/>
|
||||
if opts.Status != "" {
|
||||
<input type="hidden" name="status" value={ opts.Status }/>
|
||||
}
|
||||
</form>
|
||||
<script src="/dist/static/sort_filter.js" defer></script>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package ui
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
)
|
||||
|
||||
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-(--accent)/10 text-(--text)", 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-(--accent)" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/web/shared"
|
||||
)
|
||||
|
||||
templ VideoPlayer(data shared.WatchPageData, displayTitle string) {
|
||||
{{ streamToken := shared.ModeToken(data.InitialMode, data.ModeSources) }}
|
||||
{{ hasDub := shared.ModeAvailable(data.AvailableModes, "dub") }}
|
||||
{{ hasSub := shared.ModeAvailable(data.AvailableModes, "sub") }}
|
||||
{{ episodeTitle := data.EpisodeTitle }}
|
||||
<div
|
||||
class="group/player 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={ shared.ToJSON(data.AvailableModes) }
|
||||
data-mode-sources={ shared.ToJSON(data.ModeSources) }
|
||||
data-segments={ shared.ToJSON(data.Segments) }
|
||||
>
|
||||
<div class="group relative aspect-video w-full overflow-hidden bg-black cursor-none group-[.show-controls]/player:cursor-auto">
|
||||
<video
|
||||
class="h-full w-full"
|
||||
preload="metadata"
|
||||
crossorigin="anonymous"
|
||||
playsinline
|
||||
src={ shared.BuildStreamURL(data.InitialMode, streamToken) }
|
||||
></video>
|
||||
<div
|
||||
data-video-overlay
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-30 flex flex-col items-start bg-linear-to-b from-black/80 via-black/40 to-transparent p-4 opacity-100 transition-opacity duration-300 sm:p-6 sm:opacity-0 group-[.show-controls]/player:opacity-100 sm:group-[.show-controls]/player:opacity-100"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-white drop-shadow-md sm:text-xl md:text-2xl">{ displayTitle }</h2>
|
||||
<p class="text-sm font-medium text-white/80 drop-shadow-md sm:text-base">
|
||||
if episodeTitle != "" {
|
||||
{ fmt.Sprintf("Episode %s, %s", data.CurrentEpisode, episodeTitle) }
|
||||
} else {
|
||||
{ fmt.Sprintf("Episode %s", data.CurrentEpisode) }
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<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 shadow-xl drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] transition-opacity hover:opacity-90 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-linear-to-t from-black/90 to-transparent px-3 pb-3 pt-10 opacity-100 transition-opacity duration-300 sm:px-4 sm:pb-4 sm:pt-12 sm:opacity-0 group-[.show-controls]/player:opacity-100 sm:group-[.show-controls]/player: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>
|
||||
<line x1="15" y1="6" x2="15" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
data-volume-wrap
|
||||
class="group/volume relative flex h-9 w-9 items-center justify-center overflow-visible sm:h-10 sm:w-10"
|
||||
>
|
||||
<div
|
||||
data-volume-panel
|
||||
class="pointer-events-none absolute bottom-[calc(100%+16px)] left-1/2 z-30 -translate-x-1/2 opacity-0 invisible transition-all duration-200 group-hover/volume:pointer-events-auto group-hover/volume:opacity-100 group-hover/volume:visible focus-within:pointer-events-auto focus-within:opacity-100 focus-within:visible [&.is-dragging]:pointer-events-auto [&.is-dragging]:opacity-100 [&.is-dragging]:visible"
|
||||
>
|
||||
<div class="flex h-[100px] w-8 items-center justify-center">
|
||||
<input
|
||||
data-volume-range
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="100"
|
||||
class="h-1.5 w-20 -rotate-90 cursor-pointer appearance-none rounded-full outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_1px_rgba(0,0,0,0.18),0_1px_3px_rgba(0,0,0,0.22)] [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-[0_0_0_1px_rgba(0,0,0,0.18),0_1px_3px_rgba(0,0,0,0.22)]"
|
||||
style="background: linear-gradient(to right, #ffffff var(--volume-percent, 100%), rgba(255,255,255,0.72) var(--volume-percent, 100%)); box-shadow: 0 0 0 1px rgba(0,0,0,0.16), 0 1px 4px rgba(0,0,0,0.18);"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute top-full left-0 h-[24px] w-full"></div>
|
||||
</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"></polygon>
|
||||
<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>
|
||||
<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"></path>
|
||||
</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"></polygon>
|
||||
<line x1="16" y1="9" x2="20" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"></line>
|
||||
<line x1="20" y1="9" x2="16" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
data-volume-underline
|
||||
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={ shared.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"></path>
|
||||
</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={ shared.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"></rect>
|
||||
<path d="M8 11.5h8M8 14.5h5" stroke="white" stroke-width="1.85" stroke-linecap="round"></path>
|
||||
</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>
|
||||
<path d="M40.0106 5.45398V0L50 7.79529L40.0106 15.5914V10.3033H4.9114V40.1506H18.7558V45H2.01875e-06V5.45398H40.0106Z" fill="white"></path>
|
||||
</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>
|
||||
<path d="M11.9894 5.45398V0L2 7.79529L11.9894 15.5914V10.3033H47.0886V40.1506H33.2442V45H52V5.45398H11.9894Z" fill="white"></path>
|
||||
</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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mal/web/shared"
|
||||
)
|
||||
|
||||
templ CardButton(
|
||||
animeID int,
|
||||
title string,
|
||||
titleEnglish string,
|
||||
titleJapanese string,
|
||||
imageURL string,
|
||||
airing bool,
|
||||
inWatchlist bool,
|
||||
) {
|
||||
<button
|
||||
class={ "cursor-pointer border-0 bg-transparent p-0", templ.KV("text-white", inWatchlist), templ.KV("text-white hover:text-white/70", !inWatchlist) }
|
||||
if inWatchlist {
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=card", animeID))) }
|
||||
} else {
|
||||
hx-post="/api/watchlist/card"
|
||||
hx-vals={ shared.HxVals(map[string]interface{}{
|
||||
"anime_id": animeID,
|
||||
"anime_title": title,
|
||||
"anime_title_english": titleEnglish,
|
||||
"anime_title_japanese": titleJapanese,
|
||||
"anime_image": imageURL,
|
||||
"airing": airing,
|
||||
}) }
|
||||
}
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-swap="window.location.reload()"
|
||||
aria-label={ getWatchlistLabel(inWatchlist) }
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill={ getWatchlistFill(inWatchlist) } xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{ getWatchlistLabel(inWatchlist) }</title>
|
||||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
func getWatchlistFill(inWatchlist bool) string {
|
||||
if inWatchlist {
|
||||
return "currentColor"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
func getWatchlistLabel(inWatchlist bool) string {
|
||||
if inWatchlist {
|
||||
return "In watchlist"
|
||||
}
|
||||
return "Add to watchlist"
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mal/web/shared"
|
||||
)
|
||||
|
||||
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
|
||||
type="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"
|
||||
>
|
||||
@StatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
|
||||
@StatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "dropped", currentStatus, airing)
|
||||
@StatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing)
|
||||
if currentStatus != "" {
|
||||
<button
|
||||
type="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 StatusOption(
|
||||
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={ shared.HxVals(map[string]interface{}{
|
||||
"anime_id": animeID,
|
||||
"anime_title": animeTitle,
|
||||
"anime_title_english": animeTitleEnglish,
|
||||
"anime_title_japanese": animeTitleJapanese,
|
||||
"anime_image": animeImage,
|
||||
"status": status,
|
||||
"airing": airing,
|
||||
}) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
hx-swap="outerHTML swap:150ms"
|
||||
>
|
||||
{ formatStatus(status) }
|
||||
</button>
|
||||
}
|
||||
|
||||
func formatStatus(status string) string {
|
||||
switch status {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "dropped":
|
||||
return "Dropped"
|
||||
case "plan_to_watch":
|
||||
return "Plan to watch"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
db "mal/internal/db"
|
||||
"mal/web/shared"
|
||||
)
|
||||
|
||||
templ Progress(entry db.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", shared.FormatProgressTime(entry.CurrentTimeSeconds)) }
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package context
|
||||
|
||||
const UserKey = "mal:user"
|
||||
@@ -1,41 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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, ", ")
|
||||
}
|
||||
|
||||
func WatchTargetEpisode(currentStatus string, currentEpisode int) int {
|
||||
if currentStatus != "" && currentEpisode > 0 {
|
||||
return currentEpisode
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// BuildStreamURL constructs a stream URL from mode and token
|
||||
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))
|
||||
}
|
||||
|
||||
// FormatProgressTime formats seconds into MM:SS format
|
||||
func FormatProgressTime(seconds float64) string {
|
||||
total := int(seconds)
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
minutes := total / 60
|
||||
remainingSeconds := total % 60
|
||||
return fmt.Sprintf("%02d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
// FormatEstablishedDate extracts YYYY-MM-DD from ISO date string
|
||||
func FormatEstablishedDate(date string) string {
|
||||
if len(date) >= 10 {
|
||||
return date[:10]
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
// WatchlistURL builds the watchlist URL with query parameters
|
||||
func WatchlistURL(status string, sortBy string, sortOrder string) string {
|
||||
return fmt.Sprintf("/watchlist?status=%s&sort=%s&order=%s", status, sortBy, sortOrder)
|
||||
}
|
||||
|
||||
// AnimeURL builds the anime detail URL
|
||||
func AnimeURL(animeID int) string {
|
||||
return fmt.Sprintf("/anime/%d", animeID)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package shared
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func HxVals(v map[string]any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"mal/web/components/icons"
|
||||
"time"
|
||||
)
|
||||
|
||||
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?v=1.0.4"/>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.11" integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||
</script>
|
||||
<script src="/dist/static/theme.js?v=1.0.3" defer></script>
|
||||
<script src="/dist/static/discover.js?v=1.0.3" defer></script>
|
||||
<script src="/dist/static/anime.js?v=1.0.3" defer></script>
|
||||
<script src="/dist/static/timezone.js?v=1.0.3" defer></script>
|
||||
<script src="/dist/static/player.js?v=1.0.3" defer></script>
|
||||
</head>
|
||||
<body
|
||||
class="min-h-screen bg-(--bg) text-(--text) font-(--font) text-sm leading-normal flex flex-col"
|
||||
>
|
||||
if showHeader {
|
||||
<header class="sticky top-0 z-100 bg-(--header)">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-(--breakpoint-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-(--breakpoint-2xl) px-4 pt-5 pb-8 max-lg:px-3 max-lg:pb-6 flex-1",
|
||||
templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader),
|
||||
}>
|
||||
{ children... }
|
||||
</main>
|
||||
<footer class="border-t border-(--panel-soft) bg-(--panel) text-(--text-muted) py-6 mt-auto">
|
||||
<div class="mx-auto max-w-(--breakpoint-2xl) px-4 flex justify-between items-center">
|
||||
<p class="text-sm">
|
||||
© {time.Now().Year()} mal. Open source anime tracking.
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="https://github.com/mkelvers/mal" target="_blank" rel="noopener noreferrer" class="text-(--text-muted) hover:text-(--text) hover:no-underline">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
id="footer-theme-toggle"
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent text-(--text-muted) hover:text-(--text)"
|
||||
aria-label="Toggle theme"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/dist/static/search.js?v=1.0.3" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package shared
|
||||
|
||||
import "mal/integrations/jikan"
|
||||
|
||||
// GetProducerName extracts the default title from producer response
|
||||
func GetProducerName(producer jikan.ProducerResponse) string {
|
||||
for _, title := range producer.Data.Titles {
|
||||
if title.Type == "Default" {
|
||||
return title.Title
|
||||
}
|
||||
}
|
||||
return "Studio"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package shared
|
||||
|
||||
// TabClass returns the CSS class for watchlist filter tabs
|
||||
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-(--text-tab-active) no-underline hover:no-underline"
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"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
|
||||
EpisodeTitle string
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
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 any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Printf("ToJSON error: %v", err)
|
||||
return "{}"
|
||||
}
|
||||
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,175 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
animecomponents "mal/web/components/anime"
|
||||
components "mal/web/components"
|
||||
watchlistcomponents "mal/web/components/watchlist"
|
||||
"mal/web/shared"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ AnimeDetails(anime jikan.Anime, currentStatus string, nextEpisode int) {
|
||||
@layout.Layout("mal - " + anime.DisplayTitle(), true) {
|
||||
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="order-2 grid min-w-0 gap-8 xl:order-1">
|
||||
<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">
|
||||
@watchlistcomponents.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, shared.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">
|
||||
@components.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">
|
||||
@components.LoadingIndicator("Loading recommendations")
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="order-1 grid gap-4 bg-(--panel) p-3 max-xl:static xl:order-2">
|
||||
<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)">{ shared.JoinNames(anime.Genres) }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if shared.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)">
|
||||
@animecomponents.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)">{ shared.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)">{ shared.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)">{ shared.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)">{ shared.JoinStreamingNames(anime) }</span>
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/web/shared/layout"
|
||||
|
||||
templ Login(formError string, username string) {
|
||||
@layout.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,55 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/integrations/jikan"
|
||||
import ui "mal/web/components"
|
||||
import "fmt"
|
||||
import "mal/web/shared/layout"
|
||||
|
||||
templ Catalog() {
|
||||
@layout.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-6" 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, watchlistStatuses map[int]string, nextPage int, hasNext bool) {
|
||||
@ui.InfiniteAnimeList(animes, watchlistStatuses, 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>
|
||||
}
|
||||
}
|
||||
|
||||
templ CatalogError(message string) {
|
||||
<div class="col-span-full py-8 text-center" id="catalog-content">
|
||||
<div class="mb-3 text-base text-(--text)">
|
||||
if message != "" {
|
||||
{ message }
|
||||
} else {
|
||||
Unable to load data
|
||||
}
|
||||
</div>
|
||||
<div class="text-sm text-(--text-muted)">
|
||||
The anime catalog is temporarily unavailable. Please wait a moment and refresh the page.
|
||||
</div>
|
||||
<button
|
||||
hx-get="/api/catalog?page=1"
|
||||
hx-target="#catalog-content"
|
||||
hx-swap="outerHTML"
|
||||
class="mt-4 inline-flex cursor-pointer items-center gap-2 rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
db "mal/internal/db"
|
||||
ui "mal/web/components"
|
||||
"mal/web/shared"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ ContinueWatching(entries []db.GetContinueWatchingEntriesRow) {
|
||||
@layout.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 relative">
|
||||
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),
|
||||
TitleEnglish: nullString(entry.TitleEnglish),
|
||||
TitleJapanese: nullString(entry.TitleJapanese),
|
||||
DisableWatchlist: true,
|
||||
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)">{ shared.FormatProgressTime(entry.CurrentTimeSeconds) }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
class="absolute right-2 top-2 z-30 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 db.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 db.GetContinueWatchingEntriesRow) string {
|
||||
return db.DisplayTitle(entry.TitleEnglish, entry.TitleJapanese, entry.TitleOriginal)
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) string {
|
||||
if s.Valid {
|
||||
return s.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/integrations/jikan"
|
||||
import ui "mal/web/components"
|
||||
import "fmt"
|
||||
import "mal/web/shared/layout"
|
||||
|
||||
templ Discover() {
|
||||
@layout.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-(--text-tab-active)"
|
||||
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-(--text-tab-active)"
|
||||
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-(--text-tab-active)"
|
||||
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-6" 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, watchlistStatuses map[int]string, listType string, nextPage int, hasNext bool) {
|
||||
@ui.InfiniteAnimeList(animes, watchlistStatuses, hasNext, string(templ.URL(fmt.Sprintf("/api/discover/%s?page=%d", listType, nextPage))), "discover-content")
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
ui "mal/web/components"
|
||||
"mal/web/shared/layout"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
templ Search(q string) {
|
||||
@layout.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, watchlistStatuses map[int]string, 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, watchlistStatuses, nextPage, hasNext)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ SearchItems(query string, animes []jikan.Anime, watchlistStatuses map[int]string, nextPage int, hasNext bool) {
|
||||
@ui.InfiniteAnimeList(animes, watchlistStatuses, hasNext, string(templ.URL(fmt.Sprintf("/api/search?q=%s&page=%d", url.QueryEscape(query), nextPage))), "results")
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/web/shared/layout"
|
||||
|
||||
templ NotFoundPage() {
|
||||
@layout.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,94 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
components "mal/web/components"
|
||||
"mal/web/shared"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, watchlistStatuses map[int]string, hasNext bool, nextPage int) {
|
||||
@layout.Layout("mal - "+shared.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={ shared.GetProducerName(producer) }
|
||||
class="h-24 w-24 object-contain"
|
||||
/>
|
||||
}
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">{ shared.GetProducerName(producer) }</h1>
|
||||
if producer.Data.Established != "" {
|
||||
<p class="mt-1 text-sm text-(--text-muted)">
|
||||
Established: { shared.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-6" id="studio-anime-content">
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@components.AnimeCard(components.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
TitleEnglish: anime.TitleEnglish,
|
||||
TitleJapanese: anime.TitleJapanese,
|
||||
Airing: anime.Airing,
|
||||
WatchlistStatus: watchlistStatuses[anime.MalID],
|
||||
})
|
||||
</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, watchlistStatuses map[int]string, hasNext bool, studioID int, nextPage int) {
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@components.AnimeCard(components.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
TitleEnglish: anime.TitleEnglish,
|
||||
TitleJapanese: anime.TitleJapanese,
|
||||
Airing: anime.Airing,
|
||||
WatchlistStatus: watchlistStatuses[anime.MalID],
|
||||
})
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
@StudioLoadMore(studioID, nextPage)
|
||||
}
|
||||
<script src="/dist/static/dedupe.js" data-container="studio-anime-content" defer></script>
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
components "mal/web/components"
|
||||
"mal/web/components/ui"
|
||||
"mal/web/components/watch"
|
||||
"mal/web/components/watchlist"
|
||||
"mal/web/shared"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ WatchPage(anime jikan.Anime, data shared.WatchPageData) {
|
||||
@layout.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
|
||||
id="episodes-list"
|
||||
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"
|
||||
>
|
||||
@ui.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"
|
||||
hx-boost="true"
|
||||
>
|
||||
@watch.VideoPlayer(data, anime.DisplayTitle())
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
data-autoplay
|
||||
class="inline-flex h-8 items-center gap-1.5 bg-(--panel-soft) px-2 text-xs text-(--text) hover:bg-(--panel)"
|
||||
title="Autoplay: On"
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 6 16 12 5 18" fill="currentColor" stroke="none"></polygon>
|
||||
<line x1="19" y1="6" x2="19" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
||||
</svg>
|
||||
Autoplay: On
|
||||
</button>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
if shared.CanGoPrevEpisode(data.CurrentEpisode) {
|
||||
<a
|
||||
href={ templ.URL(shared.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 shared.CanGoNextEpisode(data.CurrentEpisode, anime.Episodes) {
|
||||
<a
|
||||
href={ templ.URL(shared.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">
|
||||
@watchlist.WatchlistDropdown(
|
||||
anime.MalID,
|
||||
anime.Title,
|
||||
anime.TitleEnglish,
|
||||
anime.TitleJapanese,
|
||||
anime.ImageURL(),
|
||||
data.CurrentStatus,
|
||||
anime.Airing,
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
@components.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>
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
db "mal/internal/db"
|
||||
components "mal/web/components"
|
||||
"mal/web/components/watchlist"
|
||||
"mal/web/shared"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ Watchlist(
|
||||
entries []db.GetUserWatchListRow,
|
||||
currentStatus string,
|
||||
sortBy string,
|
||||
sortOrder string,
|
||||
) {
|
||||
@layout.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>
|
||||
<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(shared.WatchlistURL("all", sortBy, sortOrder)) }
|
||||
class={ shared.TabClass(currentStatus == "all") }
|
||||
>
|
||||
All
|
||||
</a>
|
||||
<a
|
||||
href={ templ.URL(shared.WatchlistURL("plan_to_watch", sortBy, sortOrder)) }
|
||||
class={ shared.TabClass(currentStatus == "plan_to_watch") }
|
||||
>
|
||||
Plan to watch
|
||||
</a>
|
||||
<a
|
||||
href={ templ.URL(shared.WatchlistURL("dropped", sortBy, sortOrder)) }
|
||||
class={ shared.TabClass(currentStatus == "dropped") }
|
||||
>
|
||||
Dropped
|
||||
</a>
|
||||
<a
|
||||
href={ templ.URL(shared.WatchlistURL("completed", sortBy, sortOrder)) }
|
||||
class={ shared.TabClass(currentStatus == "completed") }
|
||||
>
|
||||
Completed
|
||||
</a>
|
||||
</div>
|
||||
@components.SortFilter(components.SortFilterOptions{
|
||||
Sort: sortBy,
|
||||
Order: sortOrder,
|
||||
Status: currentStatus,
|
||||
})
|
||||
if len(entries) == 0 {
|
||||
@components.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 {
|
||||
<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(shared.AnimeURL(int(entry.AnimeID))) }
|
||||
class="flex flex-col bg-transparent text-inherit no-underline"
|
||||
>
|
||||
<div class="relative 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 class="absolute inset-0 bg-black/0 transition-colors duration-200 group-hover:bg-black/40"></div>
|
||||
</div>
|
||||
<div class="mt-2 line-clamp-2 text-sm leading-snug text-(--text)">
|
||||
{ entry.DisplayTitle() }
|
||||
</div>
|
||||
@watchlist.Progress(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>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user