refactor(ui): standardize tailwind classes

This commit is contained in:
2026-04-15 01:19:31 +02:00
parent 2a7361f133
commit c9d3ca5cc6
14 changed files with 228 additions and 228 deletions

View File

@@ -17,7 +17,7 @@ templ AnimeCard(props AnimeCardProps) {
if props.CurrentNode { if props.CurrentNode {
<div class={ defaultString(props.Class, "min-w-0") }> <div class={ defaultString(props.Class, "min-w-0") }>
@animeCardPoster(props.ImageURL, props.Title, props.ImgClass) @animeCardPoster(props.ImageURL, props.Title, props.ImgClass)
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]") }> <div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-sm leading-snug text-(--text)") }>
{ props.Title } { props.Title }
</div> </div>
{ children... } { children... }
@@ -27,7 +27,7 @@ templ AnimeCard(props AnimeCardProps) {
@animeCardPoster(props.ImageURL, props.Title, props.ImgClass) @animeCardPoster(props.ImageURL, props.Title, props.ImgClass)
if props.Class != "notification-card" && props.Class != "schedule-card" { if props.Class != "notification-card" && props.Class != "schedule-card" {
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]") }> <div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-sm leading-snug text-(--text)") }>
{ props.Title } { props.Title }
</div> </div>
} }
@@ -37,11 +37,11 @@ templ AnimeCard(props AnimeCardProps) {
} }
templ animeCardPoster(imageURL, title, imgClass string) { templ animeCardPoster(imageURL, title, imgClass string) {
<div class="flex h-full w-full aspect-[2/3] max-h-[var(--poster-max-height)] justify-center overflow-hidden"> <div class="flex h-full w-full aspect-2/3 max-h-(--poster-max-height) justify-center overflow-hidden">
if imageURL != "" { if imageURL != "" {
<img src={ imageURL } alt={ title } class={ defaultString(imgClass, "block h-full w-full object-cover object-center") } loading="lazy"/> <img src={ imageURL } alt={ title } class={ defaultString(imgClass, "block h-full w-full object-cover object-center") } loading="lazy"/>
} else { } else {
<div class="flex h-full w-full justify-center overflow-hidden text-[0] text-transparent">No image</div> <div class="flex h-full w-full justify-center overflow-hidden text-transparent">No image</div>
} }
</div> </div>
} }

View File

@@ -3,7 +3,7 @@ package ui
templ EmptyState(title string) { templ EmptyState(title string) {
<div class="py-4"> <div class="py-4">
<div class="mb-2 text-base">{ title }</div> <div class="mb-2 text-base">{ title }</div>
<div class="text-sm text-[var(--text-muted)]"> <div class="text-sm text-(--text-muted)">
{ children... } { children... }
</div> </div>
</div> </div>

View File

@@ -1,10 +1,10 @@
package ui package ui
templ LoadingIndicator(text string) { templ LoadingIndicator(text string) {
<div class="inline-flex items-center gap-2 text-sm text-[var(--text-muted)]"> <div class="inline-flex items-center gap-2 text-sm text-(--text-muted)">
<div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div> <div class="h-1.5 w-1.5 rounded-full bg-(--text-faint)"></div>
<div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div> <div class="h-1.5 w-1.5 rounded-full bg-(--text-faint)"></div>
<div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div> <div class="h-1.5 w-1.5 rounded-full bg-(--text-faint)"></div>
<span>{ text }</span> <span>{ text }</span>
</div> </div>
} }

View File

@@ -8,17 +8,17 @@ type SortFilterOptions struct {
} }
templ SortFilter(opts SortFilterOptions) { templ SortFilter(opts SortFilterOptions) {
<div class="mb-4 flex flex-wrap items-center gap-3 bg-[var(--panel)] p-3 max-[860px]:flex-col max-[860px]:items-start max-[860px]:gap-2"> <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"> <div class="flex items-center gap-2">
<label for="sort-select" class="text-[0.78rem] text-[var(--text-muted)]">Sort by</label> <label for="sort-select" class="text-xs text-(--text-muted)">Sort by</label>
<select id="sort-select" class="h-[30px] bg-[var(--surface-select)] px-2 text-[0.78rem] text-[var(--text)]" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()"> <select id="sort-select" class="h-8 bg-(--surface-select) px-2 text-xs text-(--text)" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()">
<option value="date" selected?={ opts.Sort == "date" }>Date added</option> <option value="date" selected?={ opts.Sort == "date" }>Date added</option>
<option value="title" selected?={ opts.Sort == "title" }>Title</option> <option value="title" selected?={ opts.Sort == "title" }>Title</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label for="order-select" class="text-[0.78rem] text-[var(--text-muted)]">Order</label> <label for="order-select" class="text-xs text-(--text-muted)">Order</label>
<select id="order-select" class="h-[30px] bg-[var(--surface-select)] px-2 text-[0.78rem] text-[var(--text)]" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()"> <select id="order-select" class="h-8 bg-(--surface-select) px-2 text-xs text-(--text)" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()">
<option value="desc" selected?={ opts.Order == "desc" }>Descending</option> <option value="desc" selected?={ opts.Order == "desc" }>Descending</option>
<option value="asc" selected?={ opts.Order == "asc" }>Ascending</option> <option value="asc" selected?={ opts.Order == "asc" }>Ascending</option>
</select> </select>

View File

@@ -7,43 +7,43 @@ import "strings"
templ AnimeDetails(anime jikan.Anime, currentStatus string) { templ AnimeDetails(anime jikan.Anime, currentStatus string) {
@Layout("mal - " + anime.DisplayTitle(), true) { @Layout("mal - " + anime.DisplayTitle(), true) {
<div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]"> <div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
<div class="grid min-w-0 gap-8"> <div class="grid min-w-0 gap-8">
<div class="grid grid-cols-[220px_minmax(0,1fr)] gap-5 max-[860px]:grid-cols-[minmax(0,1fr)]"> <div class="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div class="w-[220px] max-[860px]:w-[min(230px,58vw)]"> <div class="w-56">
if anime.ImageURL() != "" { if anime.ImageURL() != "" {
<img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/> <img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
} else { } else {
<div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full justify-center overflow-hidden text-[0] text-transparent">No image</div> <div class="flex aspect-2/3 max-h-(--poster-max-height) w-full justify-center overflow-hidden text-transparent">No image</div>
} }
</div> </div>
<div> <div>
<h1>{ anime.DisplayTitle() }</h1> <h1>{ anime.DisplayTitle() }</h1>
if anime.TitleJapanese != "" { if anime.TitleJapanese != "" {
<p class="my-2 mb-3 text-[0.9rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</p> <p class="my-2 mb-3 text-sm text-(--text-muted)">{ anime.TitleJapanese }</p>
} }
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
if anime.ShortRating() != "" { if anime.ShortRating() != "" {
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortRating() }</span> <span class="text-xs text-(--text-faint)">{ anime.ShortRating() }</span>
} }
if anime.Type != "" { if anime.Type != "" {
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.Type }</span> <span class="text-xs text-(--text-faint)">{ anime.Type }</span>
} }
if anime.Episodes > 0 { if anime.Episodes > 0 {
<span class="text-[0.67rem] text-[var(--text-faint)]">{ fmt.Sprintf("%d ep", anime.Episodes) }</span> <span class="text-xs text-(--text-faint)">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
} }
if anime.ShortDuration() != "" { if anime.ShortDuration() != "" {
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortDuration() }</span> <span class="text-xs text-(--text-faint)">{ anime.ShortDuration() }</span>
} }
</div> </div>
<div class="mt-3"> <div class="mt-3">
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing) @WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
</div> </div>
<section class="mt-4 max-w-[100ch]"> <section class="mt-4 max-w-4xl">
if anime.Synopsis != "" { if anime.Synopsis != "" {
<p>{ anime.Synopsis }</p> <p>{ anime.Synopsis }</p>
} else { } else {
<p class="text-[var(--text-faint)]">No synopsis available.</p> <p class="text-(--text-faint)">No synopsis available.</p>
} }
</section> </section>
</div> </div>
@@ -61,95 +61,95 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
</div> </div>
</section> </section>
</div> </div>
<aside class="sticky top-[74px] grid gap-4 bg-[var(--panel)] p-3 max-[1040px]:static"> <aside class="sticky top-20 grid gap-4 bg-(--panel) p-3 max-xl:static">
<div class="grid gap-3"> <div class="grid gap-3">
<h3 class="mb-2 text-[0.78rem] text-[var(--text-faint)]">Details</h3> <h3 class="mb-2 text-xs text-(--text-faint)">Details</h3>
if anime.Aired.String != "" { if anime.Aired.String != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Aired</span> <span class="mt-0.5 text-sm text-(--text-faint)">Aired</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Aired.String }</span> <span class="text-sm text-(--text-muted)">{ anime.Aired.String }</span>
</div> </div>
} }
if anime.Premiered() != "" { if anime.Premiered() != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Premiered</span> <span class="mt-0.5 text-sm text-(--text-faint)">Premiered</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Premiered() }</span> <span class="text-sm text-(--text-muted)">{ anime.Premiered() }</span>
</div> </div>
} }
if anime.Status != "" { if anime.Status != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Status</span> <span class="mt-0.5 text-sm text-(--text-faint)">Status</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Status }</span> <span class="text-sm text-(--text-muted)">{ anime.Status }</span>
</div> </div>
} }
if anime.Duration != "" { if anime.Duration != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Duration</span> <span class="mt-0.5 text-sm text-(--text-faint)">Duration</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Duration }</span> <span class="text-sm text-(--text-muted)">{ anime.Duration }</span>
</div> </div>
} }
if len(anime.Genres) > 0 { if len(anime.Genres) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Genres</span> <span class="mt-0.5 text-sm text-(--text-faint)">Genres</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Genres) }</span> <span class="text-sm text-(--text-muted)">{ joinNames(anime.Genres) }</span>
</div> </div>
} }
</div> </div>
if hasExtraSidebarDetails(anime) { if hasExtraSidebarDetails(anime) {
<details class="grid gap-3"> <details class="grid gap-3">
<summary class="cursor-pointer text-[0.82rem] text-[var(--text-muted)]">More metadata</summary> <summary class="cursor-pointer text-xs text-(--text-muted)">More metadata</summary>
if anime.TitleJapanese != "" { if anime.TitleJapanese != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Japanese</span> <span class="mt-0.5 text-sm text-(--text-faint)">Japanese</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</span> <span class="text-sm text-(--text-muted)">{ anime.TitleJapanese }</span>
</div> </div>
} }
if len(anime.TitleSynonyms) > 0 { if len(anime.TitleSynonyms) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Synonyms</span> <span class="mt-0.5 text-sm text-(--text-faint)">Synonyms</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ strings.Join(anime.TitleSynonyms, ", ") }</span> <span class="text-sm text-(--text-muted)">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
</div> </div>
} }
if len(anime.Studios) > 0 { if len(anime.Studios) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Studios</span> <span class="mt-0.5 text-sm text-(--text-faint)">Studios</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Studios) }</span> <span class="text-sm text-(--text-muted)">{ joinNames(anime.Studios) }</span>
</div> </div>
} }
if len(anime.Producers) > 0 { if len(anime.Producers) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Producers</span> <span class="mt-0.5 text-sm text-(--text-faint)">Producers</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Producers) }</span> <span class="text-sm text-(--text-muted)">{ joinNames(anime.Producers) }</span>
</div> </div>
} }
if anime.Source != "" { if anime.Source != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Source</span> <span class="mt-0.5 text-sm text-(--text-faint)">Source</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Source }</span> <span class="text-sm text-(--text-muted)">{ anime.Source }</span>
</div> </div>
} }
if len(anime.Demographics) > 0 { if len(anime.Demographics) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Demographics</span> <span class="mt-0.5 text-sm text-(--text-faint)">Demographics</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Demographics) }</span> <span class="text-sm text-(--text-muted)">{ joinNames(anime.Demographics) }</span>
</div> </div>
} }
if len(anime.Themes) > 0 { if len(anime.Themes) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Themes</span> <span class="mt-0.5 text-sm text-(--text-faint)">Themes</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Themes) }</span> <span class="text-sm text-(--text-muted)">{ joinNames(anime.Themes) }</span>
</div> </div>
} }
if anime.Broadcast.String != "" { if anime.Broadcast.String != "" {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Broadcast</span> <span class="mt-0.5 text-sm text-(--text-faint)">Broadcast</span>
<span class="text-[0.84rem] text-[var(--text-muted)]" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span> <span class="text-sm text-(--text-muted)" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
</div> </div>
} }
if len(anime.Streaming) > 0 { if len(anime.Streaming) > 0 {
<div class="mt-1 grid gap-1"> <div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Streaming</span> <span class="mt-0.5 text-sm text-(--text-faint)">Streaming</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinStreamingNames(anime) }</span> <span class="text-sm text-(--text-muted)">{ joinStreamingNames(anime) }</span>
</div> </div>
} }
</details> </details>
@@ -161,12 +161,12 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
templ AnimePending(id int) { templ AnimePending(id int) {
@Layout("mal - anime pending", true) { @Layout("mal - anime pending", true) {
<div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]"> <div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
<div class="grid min-w-0 gap-8"> <div class="grid min-w-0 gap-8">
<section> <section>
<h1>Anime data is being fetched</h1> <h1>Anime data is being fetched</h1>
<p class="text-[0.9rem] text-[var(--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)">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-[0.9rem] text-[var(--text-muted)]">Refresh this page in a few seconds.</p> <p class="text-sm text-(--text-muted)">Refresh this page in a few seconds.</p>
</section> </section>
</div> </div>
</div> </div>
@@ -196,15 +196,15 @@ func joinStreamingNames(anime jikan.Anime) string {
templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) { templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) {
<div class="relative inline-block" id="watchlist-dropdown"> <div class="relative inline-block" id="watchlist-dropdown">
<button class="inline-flex h-8 cursor-pointer items-center gap-2 bg-[var(--panel-soft)] px-2 text-[0.8rem] text-[var(--text)]" onclick="toggleDropdown()" data-dropdown-trigger> <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 != "" { if currentStatus != "" {
{ formatStatus(currentStatus) } { formatStatus(currentStatus) }
} else { } else {
Add to watchlist Add to watchlist
} }
<span class="text-[0.64rem]">▾</span> <span class="text-xs">▾</span>
</button> </button>
<div class="invisible absolute left-0 top-[calc(100%+2px)] z-[110] min-w-[210px] bg-[var(--panel)] opacity-0 transition-opacity duration-150" data-dropdown-menu data-dropdown-open-classes="visible opacity-100" data-dropdown-closed-classes="invisible opacity-0"> <div class="invisible absolute left-0 top-full mt-0.5 z-50 min-w-52 bg-(--panel) opacity-0 transition-opacity duration-150" data-dropdown-menu data-dropdown-open-classes="visible opacity-100" data-dropdown-closed-classes="invisible opacity-0">
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing)
@@ -212,7 +212,7 @@ templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing)
if currentStatus != "" { if currentStatus != "" {
<button <button
class="flex w-full cursor-pointer items-center justify-between bg-transparent px-[0.62rem] py-2 text-left text-[0.78rem] text-[var(--text-muted)] hover:bg-[var(--panel-soft)] hover:text-[var(--danger)]" 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-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))) }
hx-target="#watchlist-dropdown" hx-target="#watchlist-dropdown"
hx-swap="outerHTML swap:150ms" hx-swap="outerHTML swap:150ms"
@@ -225,8 +225,8 @@ templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string
templ dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, status string, currentStatus string, airing bool) { templ dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, status string, currentStatus string, airing bool) {
<button <button
class={ class={
"flex w-full cursor-pointer items-center justify-between bg-transparent px-[0.62rem] py-2 text-left text-[0.78rem] text-[var(--text-muted)] hover:bg-[var(--panel-soft)] hover:text-[var(--text)]", "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-[var(--panel-soft)] text-[var(--text)]", status == currentStatus), templ.KV("bg-(--panel-soft) text-(--text)", status == currentStatus),
} }
hx-post="/api/watchlist" hx-post="/api/watchlist"
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) } hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) }
@@ -256,7 +256,7 @@ func formatStatus(status string) string {
templ AnimeRelationsList(relations []jikan.RelationEntry) { templ AnimeRelationsList(relations []jikan.RelationEntry) {
if len(relations) > 1 { if len(relations) > 1 {
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3" id="relations-grid"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="relations-grid">
for _, rel := range relations { for _, rel := range relations {
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: rel.Anime.MalID, ID: rel.Anime.MalID,
@@ -268,13 +268,13 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
CurrentNode: rel.IsCurrent, CurrentNode: rel.IsCurrent,
}) { }) {
if rel.Relation != "" && rel.Relation != "Current" { if rel.Relation != "" && rel.Relation != "Current" {
<div class="mt-1 text-[0.76rem] text-[var(--text-faint)]">{ rel.Relation }</div> <div class="mt-1 text-xs text-(--text-faint)">{ rel.Relation }</div>
} }
} }
} }
</div> </div>
} else { } else {
<p class="text-[0.9rem] text-[var(--text-muted)]">No related anime found.</p> <p class="text-sm text-(--text-muted)">No related anime found.</p>
} }
} }
@@ -288,7 +288,7 @@ func relationCardClass(rel jikan.RelationEntry) string {
templ AnimeRecommendations(recs []jikan.Anime) { templ AnimeRecommendations(recs []jikan.Anime) {
if len(recs) > 0 { if len(recs) > 0 {
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
for _, anime := range recs { for _, anime := range recs {
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: anime.MalID, ID: anime.MalID,
@@ -301,7 +301,7 @@ templ AnimeRecommendations(recs []jikan.Anime) {
} }
</div> </div>
} else { } else {
<p class="text-[0.9rem] text-[var(--text-muted)]">No recommendations available.</p> <p class="text-sm text-(--text-muted)">No recommendations available.</p>
} }
} }

View File

@@ -2,29 +2,29 @@ package templates
templ Login(formError string, username string) { templ Login(formError string, username string) {
@Layout("Login", false) { @Layout("Login", false) {
<div class="w-full max-w-[560px]"> <div class="w-full max-w-xl">
<div class="mx-auto w-full bg-[var(--panel)] p-6"> <div class="mx-auto w-full bg-(--panel) p-6">
<h2 class="m-0 text-[1.4rem]">Sign in</h2> <h2 class="m-0 text-2xl">Sign in</h2>
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Enter your credentials to continue.</p> <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"> <form action="/login" method="POST" class="grid gap-4">
<div class="grid gap-1"> <div class="grid gap-1">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/> <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>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="password">Password</label> <label for="password">Password</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="password" name="password" required placeholder="Your password"/> <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> </div>
<button type="submit" class="h-10 cursor-pointer border-0 bg-[var(--accent)] text-[0.9rem] font-semibold text-[var(--text-on-accent)] hover:brightness-95">Sign in</button> <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 != "" { if formError != "" {
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p> <p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ formError }</p>
} }
</form> </form>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]"> <p class="mt-5 mb-0 text-center text-sm text-(--text-muted)">
Don't have an account? <a class="text-[var(--accent)]" href="/register">Register</a> Don't have an account? <a class="text-(--accent)" href="/register">Register</a>
</p> </p>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]"> <p class="mt-5 mb-0 text-center text-sm text-(--text-muted)">
Lost access? <a class="text-[var(--accent)]" href="/recover">Recover account</a> Lost access? <a class="text-(--accent)" href="/recover">Recover account</a>
</p> </p>
</div> </div>
</div> </div>
@@ -33,29 +33,29 @@ templ Login(formError string, username string) {
templ Register(formError string, username string) { templ Register(formError string, username string) {
@Layout("Register", false) { @Layout("Register", false) {
<div class="w-full max-w-[560px]"> <div class="w-full max-w-xl">
<div class="mx-auto w-full bg-[var(--panel)] p-6"> <div class="mx-auto w-full bg-(--panel) p-6">
<h2 class="m-0 text-[1.4rem]">Register</h2> <h2 class="m-0 text-2xl">Register</h2>
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Create a new account to track anime.</p> <p class="my-3 mb-5 text-sm text-(--text-muted)">Create a new account to track anime.</p>
<form action="/register" method="POST" class="grid gap-4"> <form action="/register" method="POST" class="grid gap-4">
<div class="grid gap-1"> <div class="grid gap-1">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/> <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>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="password">Password</label> <label for="password">Password</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="password" name="password" required placeholder="Minimum 12 chars"/> <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="Minimum 12 chars"/>
</div> </div>
<p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]"> <p class="m-0 text-xs leading-normal text-(--text-faint)">
Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character. Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character.
</p> </p>
<button type="submit" class="h-10 cursor-pointer border-0 bg-[var(--accent)] text-[0.9rem] font-semibold text-[var(--text-on-accent)] hover:brightness-95">Create account</button> <button type="submit" class="h-10 cursor-pointer border-0 bg-(--accent) text-sm font-semibold text-(--text-on-accent) hover:brightness-95">Create account</button>
if formError != "" { if formError != "" {
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p> <p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ formError }</p>
} }
</form> </form>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]"> <p class="mt-5 mb-0 text-center text-sm text-(--text-muted)">
Already have an account? <a class="text-[var(--accent)]" href="/login">Sign in</a> Already have an account? <a class="text-(--accent)" href="/login">Sign in</a>
</p> </p>
</div> </div>
</div> </div>
@@ -64,18 +64,18 @@ templ Register(formError string, username string) {
templ RegistrationRecoveryKey(recoveryKey string) { templ RegistrationRecoveryKey(recoveryKey string) {
@Layout("Save recovery key", false) { @Layout("Save recovery key", false) {
<div class="w-full max-w-[560px]"> <div class="w-full max-w-xl">
<div class="mx-auto w-full bg-[var(--panel)] p-6"> <div class="mx-auto w-full bg-(--panel) p-6">
<h2>Save your recovery key</h2> <h2>Save your recovery key</h2>
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Store this key somewhere safe. It is shown only once.</p> <p class="my-3 mb-5 text-sm text-(--text-muted)">Store this key somewhere safe. It is shown only once.</p>
<div class="grid gap-2"> <div class="grid gap-2">
<p class="m-0 break-all border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] p-3 text-[0.86rem] text-[var(--text)]" id="registration-recovery-key">{ recoveryKey }</p> <p class="m-0 break-all border border-(--surface-search-focus-border) bg-(--surface-search) p-3 text-sm text-(--text)" id="registration-recovery-key">{ recoveryKey }</p>
<button type="button" class="min-w-0 justify-self-start border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-[0.72rem] py-[0.42rem] text-[0.8rem] leading-none text-[var(--text)] hover:bg-[var(--surface-input-focus)]" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button> <button type="button" class="min-w-0 justify-self-start border border-(--surface-search-focus-border) bg-(--surface-search) px-3 py-1.5 text-xs leading-none text-(--text) hover:bg-(--surface-input-focus)" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button>
</div> </div>
<p class="mt-2 min-h-[1.1rem] text-[0.75rem] leading-[1.4] text-[var(--text-faint)]" id="registration-copy-feedback" aria-live="polite"></p> <p class="mt-2 min-h-5 text-xs leading-normal text-(--text-faint)" id="registration-copy-feedback" aria-live="polite"></p>
<p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">If you lose your password, this key is the only way to recover your account without email.</p> <p class="m-0 text-xs leading-normal text-(--text-faint)">If you lose your password, this key is the only way to recover your account without email.</p>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]"> <p class="mt-5 mb-0 text-center text-sm text-(--text-muted)">
<a href="/" class="inline-flex min-h-10 items-center justify-center border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] no-underline hover:bg-[var(--panel-soft)] hover:no-underline">I saved it, continue</a> <a href="/" class="inline-flex min-h-10 items-center justify-center border border-(--surface-search-focus-border) bg-(--surface-search) px-4 text-(--text) no-underline hover:bg-(--panel-soft) hover:no-underline">I saved it, continue</a>
</p> </p>
</div> </div>
</div> </div>
@@ -84,30 +84,30 @@ templ RegistrationRecoveryKey(recoveryKey string) {
templ Recover(formError string, username string, recoveryKey string) { templ Recover(formError string, username string, recoveryKey string) {
@Layout("Recover account", false) { @Layout("Recover account", false) {
<div class="w-full max-w-[560px]"> <div class="w-full max-w-xl">
<div class="mx-auto w-full bg-[var(--panel)] p-6"> <div class="mx-auto w-full bg-(--panel) p-6">
<h2 class="m-0 text-[1.4rem]">Recover account</h2> <h2 class="m-0 text-2xl">Recover account</h2>
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Enter your username, recovery key, and a new password.</p> <p class="my-3 mb-5 text-sm text-(--text-muted)">Enter your username, recovery key, and a new password.</p>
<form action="/recover" method="POST" class="grid gap-4"> <form action="/recover" method="POST" class="grid gap-4">
<div class="grid gap-1"> <div class="grid gap-1">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/> <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>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="recovery_key">Recovery key</label> <label for="recovery_key">Recovery key</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="recovery_key" name="recovery_key" required value={ recoveryKey }/> <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="recovery_key" name="recovery_key" required value={ recoveryKey }/>
</div> </div>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="new_password">New password</label> <label for="new_password">New password</label>
<input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/> <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="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
</div> </div>
<button type="submit" class="h-10 cursor-pointer border-0 bg-[var(--accent)] text-[0.9rem] font-semibold text-[var(--text-on-accent)] hover:brightness-95">Reset password</button> <button type="submit" class="h-10 cursor-pointer border-0 bg-(--accent) text-sm font-semibold text-(--text-on-accent) hover:brightness-95">Reset password</button>
if formError != "" { if formError != "" {
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p> <p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ formError }</p>
} }
</form> </form>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]"> <p class="mt-5 mb-0 text-center text-sm text-(--text-muted)">
Remembered your password? <a class="text-[var(--accent)]" href="/login">Sign in</a> Remembered your password? <a class="text-(--accent)" href="/login">Sign in</a>
</p> </p>
</div> </div>
</div> </div>
@@ -116,18 +116,18 @@ templ Recover(formError string, username string, recoveryKey string) {
templ RecoveryComplete(newRecoveryKey string) { templ RecoveryComplete(newRecoveryKey string) {
@Layout("Recovery complete", false) { @Layout("Recovery complete", false) {
<div class="w-full max-w-[560px]"> <div class="w-full max-w-xl">
<div class="mx-auto w-full bg-[var(--panel)] p-6"> <div class="mx-auto w-full bg-(--panel) p-6">
<h2>Account recovered</h2> <h2>Account recovered</h2>
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Your password was reset and your recovery key was rotated.</p> <p class="my-3 mb-5 text-sm text-(--text-muted)">Your password was reset and your recovery key was rotated.</p>
<div class="grid gap-2"> <div class="grid gap-2">
<p class="m-0 break-all border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] p-3 text-[0.86rem] text-[var(--text)]" id="recovery-complete-key">{ newRecoveryKey }</p> <p class="m-0 break-all border border-(--surface-search-focus-border) bg-(--surface-search) p-3 text-sm text-(--text)" id="recovery-complete-key">{ newRecoveryKey }</p>
<button type="button" class="min-w-0 justify-self-start border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-[0.72rem] py-[0.42rem] text-[0.8rem] leading-none text-[var(--text)] hover:bg-[var(--surface-input-focus)]" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button> <button type="button" class="min-w-0 justify-self-start border border-(--surface-search-focus-border) bg-(--surface-search) px-3 py-1.5 text-xs leading-none text-(--text) hover:bg-(--surface-input-focus)" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button>
</div> </div>
<p class="mt-2 min-h-[1.1rem] text-[0.75rem] leading-[1.4] text-[var(--text-faint)]" id="recovery-complete-feedback" aria-live="polite"></p> <p class="mt-2 min-h-5 text-xs leading-normal text-(--text-faint)" id="recovery-complete-feedback" aria-live="polite"></p>
<p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">Replace your old recovery key with this one.</p> <p class="m-0 text-xs leading-normal text-(--text-faint)">Replace your old recovery key with this one.</p>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]"> <p class="mt-5 mb-0 text-center text-sm text-(--text-muted)">
<a href="/login" class="inline-flex min-h-10 items-center justify-center border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] no-underline hover:bg-[var(--panel-soft)] hover:no-underline">Go to login</a> <a href="/login" class="inline-flex min-h-10 items-center justify-center border border-(--surface-search-focus-border) bg-(--surface-search) px-4 text-(--text) no-underline hover:bg-(--panel-soft) hover:no-underline">Go to login</a>
</p> </p>
</div> </div>
</div> </div>
@@ -136,75 +136,75 @@ templ RecoveryComplete(newRecoveryKey string) {
templ Account(username string, createdAt string, passwordError string, passwordSuccess string, recoveryError string, recoverySuccess string, recoveryKey string) { templ Account(username string, createdAt string, passwordError string, passwordSuccess string, recoveryError string, recoverySuccess string, recoveryKey string) {
@Layout("mal - account", true) { @Layout("mal - account", true) {
<div class="mx-auto grid w-[min(720px,100%)] gap-4"> <div class="mx-auto grid w-full max-w-3xl gap-4">
<section class="grid gap-3 bg-[var(--panel)] p-5"> <section class="grid gap-3 bg-(--panel) p-5">
<h2>Account</h2> <h2>Account</h2>
<div class="grid gap-2"> <div class="grid gap-2">
<div class="grid gap-1"> <div class="grid gap-1">
<span class="text-[0.78rem] text-[var(--text-faint)]">Email / Username</span> <span class="text-xs text-(--text-faint)">Email / Username</span>
<span class="text-[0.95rem] text-[var(--text)]">{ username }</span> <span class="text-sm text-(--text)">{ username }</span>
</div> </div>
<div class="grid gap-1"> <div class="grid gap-1">
<span class="text-[0.78rem] text-[var(--text-faint)]">Created</span> <span class="text-xs text-(--text-faint)">Created</span>
<span class="text-[0.95rem] text-[var(--text)]">{ createdAt }</span> <span class="text-sm text-(--text)">{ createdAt }</span>
</div> </div>
</div> </div>
</section> </section>
<section class="grid gap-3 bg-[var(--panel)] p-5"> <section class="grid gap-3 bg-(--panel) p-5">
<h3>Change password</h3> <h3>Change password</h3>
<form action="/account/password" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Change your password now?')"> <form action="/account/password" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Change your password now?')">
<div class="grid gap-1"> <div class="grid gap-1">
<label for="current_password">Current password</label> <label for="current_password">Current password</label>
<input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="current_password" name="current_password" required/> <input class="h-10 w-full 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="current_password" name="current_password" required/>
</div> </div>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="new_password">New password</label> <label for="new_password">New password</label>
<input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/> <input class="h-10 w-full 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="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
</div> </div>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="confirm_new_password">Confirm new password</label> <label for="confirm_new_password">Confirm new password</label>
<input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="confirm_new_password" name="confirm_new_password" required/> <input class="h-10 w-full 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="confirm_new_password" name="confirm_new_password" required/>
</div> </div>
<button type="submit" class="h-10 cursor-pointer border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] hover:bg-[var(--panel-soft)]">Update password</button> <button type="submit" class="h-10 cursor-pointer border border-(--surface-search-focus-border) bg-(--surface-search) px-4 text-(--text) hover:bg-(--panel-soft)">Update password</button>
if passwordError != "" { if passwordError != "" {
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ passwordError }</p> <p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ passwordError }</p>
} }
if passwordSuccess != "" { if passwordSuccess != "" {
<p class="m-0 text-[0.82rem] text-[var(--accent)]" role="status" aria-live="polite">{ passwordSuccess }</p> <p class="m-0 text-xs text-(--accent)" role="status" aria-live="polite">{ passwordSuccess }</p>
} }
</form> </form>
</section> </section>
<section class="grid gap-3 bg-[var(--panel)] p-5"> <section class="grid gap-3 bg-(--panel) p-5">
<h3>Recovery key</h3> <h3>Recovery key</h3>
<p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">To view a new recovery key, confirm your current password. This rotates your old key.</p> <p class="m-0 text-xs leading-normal text-(--text-faint)">To view a new recovery key, confirm your current password. This rotates your old key.</p>
<form action="/account/recovery-key" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')"> <form action="/account/recovery-key" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')">
<div class="grid gap-1"> <div class="grid gap-1">
<label for="recovery_password">Current password</label> <label for="recovery_password">Current password</label>
<input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="recovery_password" name="password" required/> <input class="h-10 w-full 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="recovery_password" name="password" required/>
</div> </div>
<button type="submit" class="h-10 cursor-pointer border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] hover:bg-[var(--panel-soft)]">Show new recovery key</button> <button type="submit" class="h-10 cursor-pointer border border-(--surface-search-focus-border) bg-(--surface-search) px-4 text-(--text) hover:bg-(--panel-soft)">Show new recovery key</button>
if recoveryError != "" { if recoveryError != "" {
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ recoveryError }</p> <p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ recoveryError }</p>
} }
if recoverySuccess != "" { if recoverySuccess != "" {
<p class="m-0 text-[0.82rem] text-[var(--accent)]" role="status" aria-live="polite">{ recoverySuccess }</p> <p class="m-0 text-xs text-(--accent)" role="status" aria-live="polite">{ recoverySuccess }</p>
} }
</form> </form>
if recoveryKey != "" { if recoveryKey != "" {
<div class="grid gap-2"> <div class="grid gap-2">
<p class="m-0 break-all border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] p-3 text-[0.86rem] text-[var(--text)]" id="account-recovery-key">{ recoveryKey }</p> <p class="m-0 break-all border border-(--surface-search-focus-border) bg-(--surface-search) p-3 text-sm text-(--text)" id="account-recovery-key">{ recoveryKey }</p>
<button type="button" class="min-w-0 justify-self-start border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-[0.72rem] py-[0.42rem] text-[0.8rem] leading-none text-[var(--text)] hover:bg-[var(--surface-input-focus)]" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button> <button type="button" class="min-w-0 justify-self-start border border-(--surface-search-focus-border) bg-(--surface-search) px-3 py-1.5 text-xs leading-none text-(--text) hover:bg-(--surface-input-focus)" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button>
</div> </div>
<p class="mt-2 min-h-[1.1rem] text-[0.75rem] leading-[1.4] text-[var(--text-faint)]" id="account-copy-feedback" aria-live="polite"></p> <p class="mt-2 min-h-5 text-xs leading-normal text-(--text-faint)" id="account-copy-feedback" aria-live="polite"></p>
} }
</section> </section>
<section class="grid gap-3 bg-[var(--panel)] p-5"> <section class="grid gap-3 bg-(--panel) p-5">
<h3>Danger zone</h3> <h3>Danger zone</h3>
<form action="/logout" method="POST" class="inline-flex" onsubmit="return confirmDangerAction('Log out of this account now?')"> <form action="/logout" method="POST" class="inline-flex" onsubmit="return confirmDangerAction('Log out of this account now?')">
<button type="submit" class="h-10 cursor-pointer border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] hover:bg-[var(--panel-soft)]">Log out</button> <button type="submit" class="h-10 cursor-pointer border border-(--surface-search-focus-border) bg-(--surface-search) px-4 text-(--text) hover:bg-(--panel-soft)">Log out</button>
</form> </form>
</section> </section>
</div> </div>

View File

@@ -6,7 +6,7 @@ import "fmt"
templ Catalog() { templ Catalog() {
@Layout("mal - catalog", true) { @Layout("mal - catalog", true) {
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3" id="catalog-content"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="catalog-content">
<div class="col-span-full" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML"> <div class="col-span-full" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
@ui.LoadingIndicator("Loading catalog") @ui.LoadingIndicator("Loading catalog")
</div> </div>
@@ -21,8 +21,8 @@ templ CatalogItems(animes []jikan.Anime, nextPage int, hasNext bool) {
templ CatalogPlaceholderItems(count int) { templ CatalogPlaceholderItems(count int) {
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
<div class="pointer-events-none min-w-0" aria-hidden="true"> <div class="pointer-events-none min-w-0" aria-hidden="true">
<div class="aspect-[2/3] max-h-[var(--poster-max-height)] w-full animate-pulse bg-[var(--surface-search)]"></div> <div class="aspect-2/3 max-h-(--poster-max-height) w-full animate-pulse bg-(--surface-search)"></div>
<div class="mt-2 h-[0.9rem] w-4/5 animate-pulse bg-[var(--surface-search)]"></div> <div class="mt-2 h-4 w-4/5 animate-pulse bg-(--surface-search)"></div>
</div> </div>
} }
} }

View File

@@ -9,35 +9,35 @@ templ Discover() {
<div class="grid gap-4"> <div class="grid gap-4">
<div class="grid gap-4"> <div class="grid gap-4">
<h1>Discover</h1> <h1>Discover</h1>
<p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Browse what's airing now and what is coming soon.</p> <p class="m-0 text-sm text-(--text-muted)">Browse what's airing now and what is coming soon.</p>
</div> </div>
<div class="flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1" data-tab-group="discover"> <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 <button
class="tab-trigger shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)]" class="tab-trigger shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent)"
type="button" type="button"
hx-get="/api/discover/airing?page=1" hx-get="/api/discover/airing?page=1"
hx-target="#discover-content" hx-target="#discover-content"
hx-trigger="click" hx-trigger="click"
data-tab-trigger data-tab-trigger
data-tab-active-classes="bg-[var(--surface-tab-active)] text-[var(--accent)]" data-tab-active-classes="bg-(--surface-tab-active) text-(--accent)"
data-tab-inactive-classes="bg-[var(--panel-soft)] text-[var(--text-muted)]" data-tab-inactive-classes="bg-(--panel-soft) text-(--text-muted)"
> >
airing now airing now
</button> </button>
<button <button
class="tab-trigger shrink-0 whitespace-nowrap bg-[var(--panel-soft)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--text-muted)] hover:bg-[var(--surface-tab-hover)] hover:text-[var(--text)]" 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" type="button"
hx-get="/api/discover/upcoming?page=1" hx-get="/api/discover/upcoming?page=1"
hx-target="#discover-content" hx-target="#discover-content"
hx-trigger="click" hx-trigger="click"
data-tab-trigger data-tab-trigger
data-tab-active-classes="bg-[var(--surface-tab-active)] text-[var(--accent)]" data-tab-active-classes="bg-(--surface-tab-active) text-(--accent)"
data-tab-inactive-classes="bg-[var(--panel-soft)] text-[var(--text-muted)]" data-tab-inactive-classes="bg-(--panel-soft) text-(--text-muted)"
> >
upcoming upcoming
</button> </button>
</div> </div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
<div class="col-span-full"> <div class="col-span-full">
@ui.LoadingIndicator("Loading discover") @ui.LoadingIndicator("Loading discover")
</div> </div>

View File

@@ -28,7 +28,7 @@ templ SearchResultsWrapper(query string, animes []jikan.Anime, nextPage int, has
Try a different search term. Try a different search term.
} }
} else { } else {
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
@SearchItems(query, animes, nextPage, hasNext) @SearchItems(query, animes, nextPage, hasNext)
</div> </div>
} }

View File

@@ -17,33 +17,33 @@ templ Layout(title string, showHeader bool) {
<script src="/dist/timezone.js" defer></script> <script src="/dist/timezone.js" defer></script>
<script src="/dist/auth.js" defer></script> <script src="/dist/auth.js" defer></script>
</head> </head>
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)] font-[var(--font)] text-[14px] leading-[1.45]"> <body class="min-h-screen bg-(--bg) text-(--text) font-(--font) text-sm leading-normal">
if showHeader { if showHeader {
<header class="sticky top-0 z-[100] bg-[var(--header)]"> <header class="sticky top-0 z-100 bg-(--header)">
<div class="mx-auto flex w-full max-w-[1580px] items-center gap-4 px-4 py-3 max-[860px]:flex-wrap max-[860px]:gap-3"> <div class="mx-auto flex w-full max-w-screen-2xl items-center gap-4 px-4 py-3 max-lg:flex-wrap max-lg:gap-3">
<div class="flex min-w-0 items-center gap-5 max-[860px]:w-full max-[860px]:flex-wrap max-[860px]:gap-3" data-search-root> <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-[var(--accent)]" aria-label="mal logo"> <a href="/" class="inline-flex items-center text-(--accent)" aria-label="mal logo">
@icons.LogoIcon("h-7 w-7") @icons.LogoIcon("h-7 w-7")
</a> </a>
<div class="flex flex-wrap gap-3 text-[0.85rem] max-[860px]:w-full max-[860px]:gap-2"> <div class="flex flex-wrap gap-3 text-sm max-lg:w-full max-lg:gap-2">
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/">Catalog</a> <a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/">Catalog</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/discover">Discover</a> <a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/discover">Discover</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/notifications">Notifications</a> <a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/notifications">Notifications</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/watchlist">Watchlist</a> <a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/watchlist">Watchlist</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/account">Account</a> <a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/account">Account</a>
</div> </div>
</div> </div>
<div class="relative ml-auto min-w-[240px] w-[min(420px,45vw)] max-[860px]:ml-0 max-[860px]:w-full" data-search-root> <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"> <form action="/search" method="GET" class="w-full" id="search-form">
<input type="text" id="search-input" name="q" class="h-[34px] w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 placeholder:text-[var(--text-faint)] focus:border-[var(--surface-search-focus-border)] focus:outline-none" placeholder="Search anime..." autocomplete="off"/> <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-[calc(100%+2px)] z-[120] max-h-[min(70vh,560px)] overflow-y-auto bg-[var(--panel)]" data-search-results-container></div> <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> </form>
</div> </div>
</div> </div>
</header> </header>
} }
<main class={ <main class={
"mx-auto w-full max-w-[1580px] px-4 pt-5 pb-8 max-[860px]:px-3 max-[860px]:pb-6", "mx-auto w-full max-w-screen-2xl px-4 pt-5 pb-8 max-lg:px-3 max-lg:pb-6",
templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader), templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader),
}> }>
{ children... } { children... }

View File

@@ -2,11 +2,11 @@ package templates
templ NotFoundPage() { templ NotFoundPage() {
@Layout("mal - not found", false) { @Layout("mal - not found", false) {
<section class="w-[min(780px,calc(100vw-(1.5rem*2)))] min-h-[72vh] mx-auto py-8 px-7 grid content-center justify-items-center gap-3 text-center"> <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-[clamp(4rem,15vw,10rem)] tracking-[0.04em] leading-[0.9] text-[var(--text-muted)]">404</p> <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-[clamp(2rem,4vw,3rem)]">Page not found</h1> <h1 class="m-0 text-3xl sm:text-4xl md:text-5xl">Page not found</h1>
<p class="text-[var(--text-muted)]">The page you requested does not exist, or it was moved.</p> <p class="text-(--text-muted)">The page you requested does not exist, or it was moved.</p>
<p><a href="/" class="text-[1.05rem] text-[var(--accent)] no-underline hover:underline">Back to catalog</a></p> <p><a href="/" class="text-base text-(--accent) no-underline hover:underline">Back to catalog</a></p>
</section> </section>
} }
} }

View File

@@ -15,7 +15,7 @@ templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@Layout("mal - notifications", true) { @Layout("mal - notifications", true) {
<div class="grid gap-4"> <div class="grid gap-4">
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="mb-3 flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1"> <div class="mb-3 flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
<a href="/notifications?tab=tracking" class={ statusTabClass(activeTab == "tracking") }>Tracking</a> <a href="/notifications?tab=tracking" class={ statusTabClass(activeTab == "tracking") }>Tracking</a>
<a href="/notifications?tab=sequels" class={ statusTabClass(activeTab == "sequels") }>Sequels</a> <a href="/notifications?tab=sequels" class={ statusTabClass(activeTab == "sequels") }>Sequels</a>
</div> </div>
@@ -25,13 +25,13 @@ templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@ui.LoadingIndicator("Syncing sequel graphs...") @ui.LoadingIndicator("Syncing sequel graphs...")
</div> </div>
} else { } else {
<p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Shows you're currently watching or planning to watch.</p> <p class="m-0 text-sm text-(--text-muted)">Shows you're currently watching or planning to watch.</p>
if len(watching) == 0 { if len(watching) == 0 {
@ui.EmptyState("No airing anime in your watching list.") { @ui.EmptyState("No airing anime in your watching list.") {
<span class="text-[0.9rem] text-[var(--text-muted)]">Add currently airing shows to your watching list to see upcoming episodes here.</span> <span class="text-sm text-(--text-muted)">Add currently airing shows to your watching list to see upcoming episodes here.</span>
} }
} else { } else {
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
for _, item := range watching { for _, item := range watching {
@NotificationCard(item) @NotificationCard(item)
} }
@@ -56,7 +56,7 @@ func splitUpcomingSeasons(items []database.GetUpcomingSeasonsRow) (airing []data
templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) { templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if len(upcomingSeasons) == 0 { if len(upcomingSeasons) == 0 {
@ui.EmptyState("No upcoming seasons for anime you've watched.") { @ui.EmptyState("No upcoming seasons for anime you've watched.") {
<span class="text-[0.9rem] text-[var(--text-muted)]">As you watch more shows, new seasons will appear here.</span> <span class="text-sm text-(--text-muted)">As you watch more shows, new seasons will appear here.</span>
} }
} else { } else {
@renderSplitSeasons(upcomingSeasons) @renderSplitSeasons(upcomingSeasons)
@@ -67,9 +67,9 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true { if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true {
if len(airing) > 0 { if len(airing) > 0 {
<section class="mb-4 grid gap-3"> <section class="mb-4 grid gap-3">
<h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">Airing now</h2> <h2 class="m-0 text-xl font-semibold leading-tight">Airing now</h2>
<p class="m-0 text-[0.88rem] text-[var(--text-muted)]">These are the currently airing anime, but you're not tracking any of these.</p> <p class="m-0 text-sm text-(--text-muted)">These are the currently airing anime, but you're not tracking any of these.</p>
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
for _, item := range airing { for _, item := range airing {
@UpcomingSeasonCard(item) @UpcomingSeasonCard(item)
} }
@@ -79,9 +79,9 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if len(upcoming) > 0 { if len(upcoming) > 0 {
<section class="grid gap-3"> <section class="grid gap-3">
<h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">Announced & upcoming</h2> <h2 class="m-0 text-xl font-semibold leading-tight">Announced & upcoming</h2>
<p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Newly announced or upcoming seasons related to anime you've watched.</p> <p class="m-0 text-sm text-(--text-muted)">Newly announced or upcoming seasons related to anime you've watched.</p>
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
for _, item := range upcoming { for _, item := range upcoming {
@UpcomingSeasonCard(item) @UpcomingSeasonCard(item)
} }
@@ -99,15 +99,15 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline", Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
}) { }) {
<div class="mt-2 grid gap-1 p-0" data-notification-content> <div class="mt-2 grid gap-1 p-0" data-notification-content>
<div class="line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]"> <div class="line-clamp-2 text-sm leading-snug text-(--text)">
{ displaySeasonTitle(item) } { displaySeasonTitle(item) }
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
if item.Status.Valid { if item.Status.Valid {
<span class="text-[0.67rem] text-[var(--text-faint)]">{ seasonStatusLabel(item.Status.String) }</span> <span class="text-xs text-(--text-faint)">{ seasonStatusLabel(item.Status.String) }</span>
} }
if strings.TrimSpace(item.PrequelTitle) != "" { if strings.TrimSpace(item.PrequelTitle) != "" {
<span class="text-[0.67rem] text-[var(--text-faint)]">{ fmt.Sprintf("Sequel to %s", item.PrequelTitle) }</span> <span class="text-xs text-(--text-faint)">{ fmt.Sprintf("Sequel to %s", item.PrequelTitle) }</span>
} }
</div> </div>
</div> </div>
@@ -126,16 +126,16 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline", Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
}) { }) {
<div class="mt-2 grid gap-1 p-0" data-notification-content> <div class="mt-2 grid gap-1 p-0" data-notification-content>
<div class="line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]"> <div class="line-clamp-2 text-sm leading-snug text-(--text)">
{ displayTitle(item.Entry) } { displayTitle(item.Entry) }
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
if item.Anime.Broadcast.String != "" { if item.Anime.Broadcast.String != "" {
<span class="text-[0.67rem] text-[var(--text-faint)]" data-jst-text={ item.Anime.Broadcast.String } data-broadcast-day={ item.Anime.Broadcast.Day } data-broadcast-time={ item.Anime.Broadcast.Time } data-broadcast-timezone={ item.Anime.Broadcast.Timezone }>{ item.Anime.Broadcast.String }</span> <span class="text-xs text-(--text-faint)" data-jst-text={ item.Anime.Broadcast.String } data-broadcast-day={ item.Anime.Broadcast.Day } data-broadcast-time={ item.Anime.Broadcast.Time } data-broadcast-timezone={ item.Anime.Broadcast.Timezone }>{ item.Anime.Broadcast.String }</span>
<span class="text-[0.67rem] text-[var(--text-faint)]" data-next-airing="pending">Calculating next episode time...</span> <span class="text-xs text-(--text-faint)" data-next-airing="pending">Calculating next episode time...</span>
} }
if item.Anime.Episodes > 0 { if item.Anime.Episodes > 0 {
<span class="text-[0.67rem] text-[var(--text-faint)]"> <span class="text-xs text-(--text-faint)">
if item.Entry.CurrentEpisode.Valid { if item.Entry.CurrentEpisode.Valid {
{ fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) } { fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) }
} else { } else {
@@ -143,7 +143,7 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
} }
</span> </span>
} else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 { } else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 {
<span class="text-[0.67rem] text-[var(--text-faint)]"> <span class="text-xs text-(--text-faint)">
{ fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64) } { fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64) }
</span> </span>
} }
@@ -174,9 +174,9 @@ func seasonStatusLabel(status string) string {
} }
func statusTabClass(active bool) string { func statusTabClass(active bool) string {
base := "shrink-0 whitespace-nowrap bg-[var(--panel-soft)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--text-muted)] no-underline hover:bg-[var(--surface-tab-hover)] hover:text-[var(--text)] hover:no-underline" base := "shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) no-underline hover:bg-(--surface-tab-hover) hover:text-(--text) hover:no-underline"
if active { if active {
return "shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)] no-underline hover:no-underline" return "shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent) no-underline hover:no-underline"
} }
return base return base
} }

View File

@@ -8,24 +8,24 @@ import (
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) { templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
@Layout("mal - watchlist", true) { @Layout("mal - watchlist", true) {
<div class="mb-4 flex items-end justify-between gap-4 max-[860px]:flex-col max-[860px]:items-start"> <div class="mb-4 flex items-end justify-between gap-4 max-lg:flex-col max-lg:items-start">
<div class="grid gap-1"> <div class="grid gap-1">
<h2>Watchlist</h2> <h2>Watchlist</h2>
<p class="m-0 text-[0.86rem] text-[var(--text-muted)]">Track what you're watching with less noise.</p> <p class="m-0 text-sm text-(--text-muted)">Track what you're watching with less noise.</p>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2 max-[860px]:w-full max-[860px]:justify-start"> <div class="flex flex-wrap items-center justify-end gap-2 max-lg:w-full max-lg:justify-start">
<a href="/api/watchlist/export" class="inline-flex min-w-16 items-center justify-center px-[0.45rem] py-[0.24rem] text-center text-[0.8rem] leading-[1.2] text-[var(--text-muted)] no-underline hover:text-[var(--accent)] hover:no-underline">Export</a> <a href="/api/watchlist/export" class="inline-flex min-w-16 items-center justify-center px-2 py-1 text-center text-xs leading-tight text-(--text-muted) no-underline hover:text-(--accent) hover:no-underline">Export</a>
<button class="inline-flex min-w-16 cursor-pointer items-center justify-center border-0 bg-transparent px-[0.45rem] py-[0.24rem] text-center text-[0.8rem] leading-[1.2] text-[var(--text-muted)] hover:text-[var(--accent)]" type="button" onclick="document.getElementById('import-file').click()">Import</button> <button class="inline-flex min-w-16 cursor-pointer items-center justify-center border-0 bg-transparent px-2 py-1 text-center text-xs leading-tight text-(--text-muted) hover:text-(--accent)" type="button" onclick="document.getElementById('import-file').click()">Import</button>
<form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="hidden"> <form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="hidden">
<input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/> <input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/>
</form> </form>
<div class="flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1"> <div class="flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "grid") }>Grid</a> <a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "grid") }>Grid</a>
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a> <a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3 flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1"> <div class="mb-3 flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a> <a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a>
<a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a> <a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a>
<a href={ templ.URL(watchlistURL(layout, "continuing", sortBy, sortOrder)) } class={ tabClass(currentStatus == "continuing") }>Continuing</a> <a href={ templ.URL(watchlistURL(layout, "continuing", sortBy, sortOrder)) } class={ tabClass(currentStatus == "continuing") }>Continuing</a>
@@ -47,7 +47,7 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
} }
} else { } else {
if layout == "grid" { if layout == "grid" {
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
for _, entry := range entries { for _, entry := range entries {
<div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }> <div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
@@ -56,7 +56,7 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
ImageURL: entry.ImageUrl, ImageURL: entry.ImageUrl,
}) })
<button <button
class="absolute right-2 top-2 h-[22px] w-[22px] cursor-pointer border-0 bg-[var(--overlay-subtle)] text-[var(--text-muted)] opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-[var(--danger)]" class="absolute right-2 top-2 h-6 w-6 cursor-pointer border-0 bg-(--overlay-subtle) text-(--text-muted) opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-(--danger)"
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) } hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) } hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
hx-swap="delete" hx-swap="delete"
@@ -65,30 +65,30 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
} }
</div> </div>
} else { } else {
<table class="block w-full overflow-x-auto whitespace-nowrap bg-[var(--panel)] md:table md:overflow-visible md:whitespace-normal"> <table class="block w-full overflow-x-auto whitespace-nowrap bg-(--panel) md:table md:overflow-visible md:whitespace-normal">
<thead> <thead>
<tr> <tr>
<th class="p-[0.6rem]"></th> <th class="p-2.5"></th>
<th class="p-[0.6rem] text-left text-[0.67rem] text-[var(--text-faint)]">Title</th> <th class="p-2.5 text-left text-xs text-(--text-faint)">Title</th>
<th class="p-[0.6rem]"></th> <th class="p-2.5"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
for _, entry := range entries { for _, entry := range entries {
<tr class="hover:bg-[var(--panel-soft)]" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }> <tr class="hover:bg-(--panel-soft)" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
<td class="p-[0.6rem]"> <td class="p-2.5">
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }> <a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-[2/3] w-9 object-cover" loading="lazy"/> <img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-2/3 w-9 object-cover" loading="lazy"/>
</a> </a>
</td> </td>
<td class="p-[0.6rem] font-medium"> <td class="p-2.5 font-medium">
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }> <a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
{ entry.DisplayTitle() } { entry.DisplayTitle() }
</a> </a>
</td> </td>
<td class="w-[90px] p-[0.6rem]"> <td class="w-24 p-2.5">
<button <button
class="cursor-pointer border-0 bg-transparent p-0 text-[0.8rem] text-[var(--text-muted)] hover:text-[var(--danger)]" class="cursor-pointer border-0 bg-transparent p-0 text-xs text-(--text-muted) hover:text-(--danger)"
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) } hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) } hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
hx-swap="delete" hx-swap="delete"
@@ -104,9 +104,9 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
} }
func tabClass(active bool) string { func tabClass(active bool) string {
base := "shrink-0 whitespace-nowrap bg-[var(--panel-soft)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--text-muted)] no-underline hover:bg-[var(--surface-tab-hover)] hover:text-[var(--text)] hover:no-underline" base := "shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) no-underline hover:bg-(--surface-tab-hover) hover:text-(--text) hover:no-underline"
if active { if active {
return "shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)] no-underline hover:no-underline" return "shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent) no-underline hover:no-underline"
} }
return base return base
} }

View File

@@ -36,18 +36,18 @@ const clearSearchResults = (): void => {
const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => { const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => {
const item = document.createElement('a') const item = document.createElement('a')
item.className = 'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-[var(--panel-soft)] hover:no-underline' item.className = 'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-(--panel-soft) hover:no-underline'
item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))) item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')))
if (isSafeImageUrl(result.image)) { if (isSafeImageUrl(result.image)) {
const img = document.createElement('img') const img = document.createElement('img')
img.className = 'aspect-[2/3] w-[42px] shrink-0 object-cover bg-[var(--surface-thumb)]' img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)'
img.setAttribute('src', result.image || '') img.setAttribute('src', result.image || '')
img.setAttribute('alt', String(result.title || '')) img.setAttribute('alt', String(result.title || ''))
item.appendChild(img) item.appendChild(img)
} else { } else {
const noImage = document.createElement('div') const noImage = document.createElement('div')
noImage.className = 'aspect-[2/3] w-[42px] shrink-0 bg-[var(--surface-thumb)] text-[0] text-transparent' noImage.className = 'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent'
noImage.textContent = 'no image' noImage.textContent = 'no image'
item.appendChild(noImage) item.appendChild(noImage)
} }
@@ -56,12 +56,12 @@ const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement =>
info.className = 'grid min-w-0 gap-px' info.className = 'grid min-w-0 gap-px'
const itemTitle = document.createElement('div') const itemTitle = document.createElement('div')
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]' itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)'
itemTitle.textContent = String(result.title || '') itemTitle.textContent = String(result.title || '')
info.appendChild(itemTitle) info.appendChild(itemTitle)
const itemType = document.createElement('div') const itemType = document.createElement('div')
itemType.className = 'text-[0.67rem] text-[var(--text-faint)]' itemType.className = 'text-[0.67rem] text-(--text-faint)'
itemType.textContent = String(result.type || '') itemType.textContent = String(result.type || '')
info.appendChild(itemType) info.appendChild(itemType)
@@ -83,7 +83,7 @@ const renderQuickSearchResults = (query: string, results: QuickSearchResult[]):
searchResults.className = 'grid' searchResults.className = 'grid'
const title = document.createElement('div') const title = document.createElement('div')
title.className = 'px-3 py-2 text-[0.68rem] text-[var(--text-faint)]' title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)'
title.textContent = 'Anime' title.textContent = 'Anime'
searchResults.appendChild(title) searchResults.appendChild(title)
@@ -92,7 +92,7 @@ const renderQuickSearchResults = (query: string, results: QuickSearchResult[]):
}) })
const viewAll = document.createElement('a') const viewAll = document.createElement('a')
viewAll.className = 'bg-[var(--surface-search-view-all)] px-3 py-2 text-center text-[0.8rem] text-[var(--text-muted)] no-underline hover:bg-[var(--panel-soft)] hover:text-[var(--text)] hover:no-underline' viewAll.className = 'bg-(--surface-search-view-all) px-3 py-2 text-center text-[0.8rem] text-(--text-muted) no-underline hover:bg-(--panel-soft) hover:text-(--text) hover:no-underline'
viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)) viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query))
viewAll.textContent = 'View all results for ' + query viewAll.textContent = 'View all results for ' + query
searchResults.appendChild(viewAll) searchResults.appendChild(viewAll)