refactor: remove old templ-based web system

This commit is contained in:
2026-05-01 15:46:35 +02:00
committed by Mikkel Elvers
parent 2c6d28cf01
commit af88f8c62c
34 changed files with 0 additions and 2016 deletions

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}
}

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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"
}

View File

@@ -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
}
}

View File

@@ -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>
}
}

View File

@@ -1,3 +0,0 @@
package context
const UserKey = "mal:user"

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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">
&copy; {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>
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
}

View File

@@ -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"
>&times;</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 ""
}

View File

@@ -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")
}

View File

@@ -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")
}

View File

@@ -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>
}
}

View File

@@ -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>
}

View File

@@ -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>
}
}

View File

@@ -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"
>
&times;
</button>
</div>
}
</div>
}
}
}