refactor: migrate templates to tailwind utilities
This commit is contained in:
@@ -15,37 +15,37 @@ type AnimeCardProps struct {
|
||||
|
||||
templ AnimeCard(props AnimeCardProps) {
|
||||
if props.CurrentNode {
|
||||
<div class={ defaultString(props.Class, "catalog-item") }>
|
||||
<div class={ defaultString(props.Class, "min-w-0") }>
|
||||
if props.ImageURL != "" {
|
||||
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "catalog-thumb") } loading="lazy"/>
|
||||
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "block aspect-[2/3] max-h-[var(--poster-max-height)] w-full object-cover object-center") } loading="lazy"/>
|
||||
} else {
|
||||
<div class="no-image">No image</div>
|
||||
<div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
|
||||
}
|
||||
<div class={ defaultString(props.TitleClass, "catalog-title") }>
|
||||
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]") }>
|
||||
{ props.Title }
|
||||
</div>
|
||||
{ children... }
|
||||
</div>
|
||||
} else {
|
||||
<a href={ templ.URL(fmt.Sprintf("/anime/%d", props.ID)) } class={ props.Class }>
|
||||
<a href={ templ.URL(fmt.Sprintf("/anime/%d", props.ID)) } class={ defaultString(props.Class, "flex flex-col bg-transparent text-inherit no-underline") }>
|
||||
if props.Class == "notification-card" || props.Class == "schedule-card" {
|
||||
<div class={ defaultString(props.ImgClass, "schedule-card-image") }>
|
||||
<div class={ defaultString(props.ImgClass, "flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden") }>
|
||||
if props.ImageURL != "" {
|
||||
<img src={ props.ImageURL } alt={ props.Title } loading="lazy"/>
|
||||
<img src={ props.ImageURL } alt={ props.Title } class="block h-full w-full object-cover object-center" loading="lazy"/>
|
||||
} else {
|
||||
<div class="no-image">No image</div>
|
||||
<div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
if props.ImageURL != "" {
|
||||
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "catalog-thumb") } loading="lazy"/>
|
||||
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "block aspect-[2/3] max-h-[var(--poster-max-height)] w-full object-cover object-center") } loading="lazy"/>
|
||||
} else {
|
||||
<div class="no-image">No image</div>
|
||||
<div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
|
||||
}
|
||||
}
|
||||
|
||||
if props.Class != "notification-card" && props.Class != "schedule-card" {
|
||||
<div class={ defaultString(props.TitleClass, "catalog-title") }>
|
||||
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]") }>
|
||||
{ props.Title }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
|
||||
templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, containerID string) {
|
||||
for _, anime := range animes {
|
||||
<div class="catalog-item" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@CatalogItem(anime)
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
<div class="scroll-trigger full-span-trigger" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
|
||||
<div class="col-span-full h-px w-full" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
|
||||
}
|
||||
<script data-container={ containerID }>
|
||||
(function() {
|
||||
@@ -20,7 +20,7 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
|
||||
const currentScript = scripts[scripts.length - 1];
|
||||
const actualID = currentScript.getAttribute('data-container');
|
||||
const container = document.getElementById(actualID) || document;
|
||||
const items = container.querySelectorAll('.catalog-item[data-id]');
|
||||
const items = container.querySelectorAll('[data-id]');
|
||||
const seen = new Set();
|
||||
items.forEach(item => {
|
||||
const id = item.getAttribute('data-id');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package ui
|
||||
|
||||
templ EmptyState(title string) {
|
||||
<div class="empty-state py-4">
|
||||
<div class="empty-state-title mb-2 text-base">{ title }</div>
|
||||
<div class="empty-state-text text-sm text-[var(--color-text-muted)]">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 text-base">{ title }</div>
|
||||
<div class="text-sm text-[var(--text-muted)]">
|
||||
{ children... }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package ui
|
||||
|
||||
templ LoadingIndicator(text string) {
|
||||
<div class="loading-indicator inline-flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
|
||||
<div class="loading-dot h-1.5 w-1.5 rounded-full bg-[var(--color-text-faint)]"></div>
|
||||
<div class="loading-dot h-1.5 w-1.5 rounded-full bg-[var(--color-text-faint)]"></div>
|
||||
<div class="loading-dot h-1.5 w-1.5 rounded-full bg-[var(--color-text-faint)]"></div>
|
||||
<div class="inline-flex items-center gap-2 text-sm text-[var(--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-[var(--text-faint)]"></div>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div>
|
||||
<span>{ text }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,23 +8,23 @@ type SortFilterOptions struct {
|
||||
}
|
||||
|
||||
templ SortFilter(opts SortFilterOptions) {
|
||||
<div class="sort-filter">
|
||||
<div class="sort-filter-group">
|
||||
<label for="sort-select">Sort by</label>
|
||||
<select id="sort-select" class="sort-filter-select" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()">
|
||||
<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="flex items-center gap-2">
|
||||
<label for="sort-select" class="text-[0.78rem] text-[var(--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()">
|
||||
<option value="date" selected?={ opts.Sort == "date" }>Date added</option>
|
||||
<option value="title" selected?={ opts.Sort == "title" }>Title</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sort-filter-group">
|
||||
<label for="order-select">Order</label>
|
||||
<select id="order-select" class="sort-filter-select" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="order-select" class="text-[0.78rem] text-[var(--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()">
|
||||
<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="is-hidden">
|
||||
<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.View != "" {
|
||||
|
||||
@@ -7,150 +7,150 @@ import "strings"
|
||||
|
||||
templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
||||
@Layout("mal - " + anime.DisplayTitle(), true) {
|
||||
<div class="anime-page">
|
||||
<div class="anime-main">
|
||||
<div class="anime-hero anime-surface">
|
||||
<div class="anime-poster">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]">
|
||||
<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="w-[220px] max-[860px]:w-[min(230px,58vw)]">
|
||||
if anime.ImageURL() != "" {
|
||||
<img src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
|
||||
<img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
|
||||
} else {
|
||||
<div class="no-image">No image</div>
|
||||
<div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
|
||||
}
|
||||
</div>
|
||||
<div class="anime-info">
|
||||
<div>
|
||||
<h1>{ anime.DisplayTitle() }</h1>
|
||||
if anime.TitleJapanese != "" {
|
||||
<p class="anime-alt-title">{ anime.TitleJapanese }</p>
|
||||
<p class="my-2 mb-3 text-[0.9rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</p>
|
||||
}
|
||||
<div class="anime-quick-info">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if anime.ShortRating() != "" {
|
||||
<span class="info-tag">{ anime.ShortRating() }</span>
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortRating() }</span>
|
||||
}
|
||||
if anime.Type != "" {
|
||||
<span class="info-tag">{ anime.Type }</span>
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.Type }</span>
|
||||
}
|
||||
if anime.Episodes > 0 {
|
||||
<span class="info-tag">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
|
||||
}
|
||||
if anime.ShortDuration() != "" {
|
||||
<span class="info-tag">{ anime.ShortDuration() }</span>
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortDuration() }</span>
|
||||
}
|
||||
</div>
|
||||
<div class="anime-actions">
|
||||
<div class="mt-3">
|
||||
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
|
||||
</div>
|
||||
<section class="anime-synopsis anime-section">
|
||||
<section class="mt-4 max-w-[100ch]">
|
||||
if anime.Synopsis != "" {
|
||||
<p>{ anime.Synopsis }</p>
|
||||
} else {
|
||||
<p class="no-synopsis">No synopsis available.</p>
|
||||
<p class="text-[var(--text-faint)]">No synopsis available.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<section class="anime-relations anime-surface anime-section">
|
||||
<section>
|
||||
<h3>Related</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading relations")
|
||||
</div>
|
||||
</section>
|
||||
<section class="anime-recommendations anime-surface anime-section">
|
||||
<section>
|
||||
<h3>Recommendations</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading recommendations")
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="anime-sidebar anime-surface">
|
||||
<div class="anime-side-section">
|
||||
<h3>Details</h3>
|
||||
<aside class="sticky top-[74px] grid gap-4 bg-[var(--panel)] p-3 max-[1040px]:static">
|
||||
<div class="grid gap-3">
|
||||
<h3 class="mb-2 text-[0.78rem] text-[var(--text-faint)]">Details</h3>
|
||||
if anime.Aired.String != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Aired</span>
|
||||
<span class="sidebar-value">{ anime.Aired.String }</span>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Aired</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Aired.String }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Premiered() != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Premiered</span>
|
||||
<span class="sidebar-value">{ anime.Premiered() }</span>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Premiered</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Premiered() }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Status != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Status</span>
|
||||
<span class="sidebar-value">{ anime.Status }</span>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Status</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Status }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Duration != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Duration</span>
|
||||
<span class="sidebar-value">{ anime.Duration }</span>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Duration</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Duration }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Genres) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Genres</span>
|
||||
<span class="sidebar-value">{ joinNames(anime.Genres) }</span>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Genres</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Genres) }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if hasExtraSidebarDetails(anime) {
|
||||
<details class="anime-side-section side-details-more">
|
||||
<summary>More metadata</summary>
|
||||
<details class="grid gap-3">
|
||||
<summary class="cursor-pointer text-[0.82rem] text-[var(--text-muted)]">More metadata</summary>
|
||||
if anime.TitleJapanese != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Japanese</span>
|
||||
<span class="sidebar-value">{ anime.TitleJapanese }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Japanese</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.TitleSynonyms) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Synonyms</span>
|
||||
<span class="sidebar-value">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Synonyms</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Studios) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Studios</span>
|
||||
<span class="sidebar-value">{ joinNames(anime.Studios) }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Studios</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Studios) }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Producers) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Producers</span>
|
||||
<span class="sidebar-value">{ joinNames(anime.Producers) }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Producers</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Producers) }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Source != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Source</span>
|
||||
<span class="sidebar-value">{ anime.Source }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Source</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Source }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Demographics) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Demographics</span>
|
||||
<span class="sidebar-value">{ joinNames(anime.Demographics) }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Demographics</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Demographics) }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Themes) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Themes</span>
|
||||
<span class="sidebar-value">{ joinNames(anime.Themes) }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Themes</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Themes) }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Broadcast.String != "" {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Broadcast</span>
|
||||
<span class="sidebar-value" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Broadcast</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Streaming) > 0 {
|
||||
<div class="sidebar-row">
|
||||
<span class="sidebar-label">Streaming</span>
|
||||
<span class="sidebar-value">{ joinStreamingNames(anime) }</span>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Streaming</span>
|
||||
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinStreamingNames(anime) }</span>
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
@@ -161,12 +161,12 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
||||
|
||||
templ AnimePending(id int) {
|
||||
@Layout("mal - anime pending", true) {
|
||||
<div class="anime-page">
|
||||
<div class="anime-main">
|
||||
<section class="anime-surface anime-section">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]">
|
||||
<div class="grid min-w-0 gap-8">
|
||||
<section>
|
||||
<h1>Anime data is being fetched</h1>
|
||||
<p class="empty-inline-note">We could not load this anime right now. A background worker is retrying data fetch for anime #{ fmt.Sprintf("%d", id) }.</p>
|
||||
<p class="empty-inline-note">Refresh this page in a few seconds.</p>
|
||||
<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-[0.9rem] text-[var(--text-muted)]">Refresh this page in a few seconds.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,25 +195,24 @@ func joinStreamingNames(anime jikan.Anime) string {
|
||||
}
|
||||
|
||||
templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) {
|
||||
<div class="dropdown" id="watchlist-dropdown">
|
||||
<button class="dropdown-trigger" onclick="toggleDropdown()">
|
||||
<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()">
|
||||
if currentStatus != "" {
|
||||
{ formatStatus(currentStatus) }
|
||||
} else {
|
||||
Add to watchlist
|
||||
}
|
||||
<span class="dropdown-arrow">▾</span>
|
||||
<span class="text-[0.64rem]">▾</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<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>
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "dropped", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing)
|
||||
if currentStatus != "" {
|
||||
<div class="dropdown-divider"></div>
|
||||
<button
|
||||
class="dropdown-item remove"
|
||||
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)]"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
hx-swap="outerHTML swap:150ms"
|
||||
@@ -225,7 +224,10 @@ 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) {
|
||||
<button
|
||||
class={ "dropdown-item", templ.KV("active", status == currentStatus) }
|
||||
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)]",
|
||||
templ.KV("bg-[var(--panel-soft)] text-[var(--text)]", status == currentStatus),
|
||||
}
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
@@ -254,7 +256,7 @@ func formatStatus(status string) string {
|
||||
|
||||
templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
||||
if len(relations) > 1 {
|
||||
<div class="relations-grid" id="relations-grid">
|
||||
<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">
|
||||
for _, rel := range relations {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: rel.Anime.MalID,
|
||||
@@ -266,13 +268,13 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
||||
CurrentNode: rel.IsCurrent,
|
||||
}) {
|
||||
if rel.Relation != "" && rel.Relation != "Current" {
|
||||
<div class="relation-type">{ rel.Relation }</div>
|
||||
<div class="mt-1 text-[0.76rem] text-[var(--text-faint)]">{ rel.Relation }</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="empty-inline-note">No related anime found.</p>
|
||||
<p class="text-[0.9rem] text-[var(--text-muted)]">No related anime found.</p>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +288,7 @@ func relationCardClass(rel jikan.RelationEntry) string {
|
||||
|
||||
templ AnimeRecommendations(recs []jikan.Anime) {
|
||||
if len(recs) > 0 {
|
||||
<div class="relations-grid">
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
|
||||
for _, anime := range recs {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
@@ -299,7 +301,7 @@ templ AnimeRecommendations(recs []jikan.Anime) {
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="empty-inline-note">No recommendations available.</p>
|
||||
<p class="text-[0.9rem] text-[var(--text-muted)]">No recommendations available.</p>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,29 +2,29 @@ package templates
|
||||
|
||||
templ Login(formError string, username string) {
|
||||
@Layout("Login", false) {
|
||||
<div class="auth-shell w-full max-w-[560px]">
|
||||
<div class="login-container w-full mx-auto p-6 bg-[var(--color-panel)]">
|
||||
<div class="w-full max-w-[560px]">
|
||||
<div class="mx-auto w-full bg-[var(--panel)] p-6">
|
||||
<h2 class="m-0 text-[1.4rem]">Sign in</h2>
|
||||
<p class="login-subtitle my-3 mb-5 text-[0.95rem] text-[var(--color-text-muted)]">Enter your credentials to continue.</p>
|
||||
<form action="/login" method="POST" class="login-form grid gap-4">
|
||||
<div class="form-group grid gap-1">
|
||||
<p class="my-3 mb-5 text-[0.95rem] text-[var(--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 type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
|
||||
<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 }/>
|
||||
</div>
|
||||
<div class="form-group grid gap-1">
|
||||
<div class="grid gap-1">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required placeholder="Your password"/>
|
||||
<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"/>
|
||||
</div>
|
||||
<button type="submit" class="login-button h-10 border-0 bg-[var(--color-accent)] text-[var(--text-on-accent)] text-[0.9rem] font-semibold cursor-pointer hover:brightness-95">Sign in</button>
|
||||
<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>
|
||||
if formError != "" {
|
||||
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p>
|
||||
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p>
|
||||
}
|
||||
</form>
|
||||
<p class="auth-switch-row mt-5 mb-0 text-center text-[0.9rem] text-[var(--color-text-muted)]">
|
||||
Don't have an account? <a href="/register">Register</a>
|
||||
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
|
||||
Don't have an account? <a class="text-[var(--accent)]" href="/register">Register</a>
|
||||
</p>
|
||||
<p class="auth-switch-row mt-5 mb-0 text-center text-[0.9rem] text-[var(--color-text-muted)]">
|
||||
Lost access? <a href="/recover">Recover account</a>
|
||||
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
|
||||
Lost access? <a class="text-[var(--accent)]" href="/recover">Recover account</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,29 +33,29 @@ templ Login(formError string, username string) {
|
||||
|
||||
templ Register(formError string, username string) {
|
||||
@Layout("Register", false) {
|
||||
<div class="auth-shell w-full max-w-[560px]">
|
||||
<div class="login-container w-full mx-auto p-6 bg-[var(--color-panel)]">
|
||||
<div class="w-full max-w-[560px]">
|
||||
<div class="mx-auto w-full bg-[var(--panel)] p-6">
|
||||
<h2 class="m-0 text-[1.4rem]">Register</h2>
|
||||
<p class="login-subtitle my-3 mb-5 text-[0.95rem] text-[var(--color-text-muted)]">Create a new account to track anime.</p>
|
||||
<form action="/register" method="POST" class="login-form grid gap-4">
|
||||
<div class="form-group grid gap-1">
|
||||
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Create a new account to track anime.</p>
|
||||
<form action="/register" method="POST" class="grid gap-4">
|
||||
<div class="grid gap-1">
|
||||
<label for="username">Username / Email</label>
|
||||
<input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
|
||||
<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 }/>
|
||||
</div>
|
||||
<div class="form-group grid gap-1">
|
||||
<div class="grid gap-1">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required placeholder="Minimum 12 chars"/>
|
||||
<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"/>
|
||||
</div>
|
||||
<p class="auth-password-note">
|
||||
<p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">
|
||||
Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character.
|
||||
</p>
|
||||
<button type="submit" class="login-button h-10 border-0 bg-[var(--color-accent)] text-[var(--text-on-accent)] text-[0.9rem] font-semibold cursor-pointer hover:brightness-95">Create account</button>
|
||||
<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>
|
||||
if formError != "" {
|
||||
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p>
|
||||
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p>
|
||||
}
|
||||
</form>
|
||||
<p class="auth-switch-row mt-5 mb-0 text-center text-[0.9rem] text-[var(--color-text-muted)]">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
|
||||
Already have an account? <a class="text-[var(--accent)]" href="/login">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,18 +64,18 @@ templ Register(formError string, username string) {
|
||||
|
||||
templ RegistrationRecoveryKey(recoveryKey string) {
|
||||
@Layout("Save recovery key", false) {
|
||||
<div class="auth-shell">
|
||||
<div class="login-container">
|
||||
<div class="w-full max-w-[560px]">
|
||||
<div class="mx-auto w-full bg-[var(--panel)] p-6">
|
||||
<h2>Save your recovery key</h2>
|
||||
<p class="login-subtitle">Store this key somewhere safe. It is shown only once.</p>
|
||||
<div class="recovery-key-row">
|
||||
<p class="recovery-key-box" id="registration-recovery-key">{ recoveryKey }</p>
|
||||
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button>
|
||||
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Store this key somewhere safe. It is shown only once.</p>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<p class="auth-password-note recovery-copy-feedback" id="registration-copy-feedback" aria-live="polite"></p>
|
||||
<p class="auth-password-note">If you lose your password, this key is the only way to recover your account without email.</p>
|
||||
<p class="auth-switch-row">
|
||||
<a href="/" class="auth-primary-link">I saved it, continue</a>
|
||||
<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="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="mt-5 mb-0 text-center text-[0.9rem] text-[var(--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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,30 +84,30 @@ templ RegistrationRecoveryKey(recoveryKey string) {
|
||||
|
||||
templ Recover(formError string, username string, recoveryKey string) {
|
||||
@Layout("Recover account", false) {
|
||||
<div class="auth-shell w-full max-w-[560px]">
|
||||
<div class="login-container w-full mx-auto p-6 bg-[var(--color-panel)]">
|
||||
<div class="w-full max-w-[560px]">
|
||||
<div class="mx-auto w-full bg-[var(--panel)] p-6">
|
||||
<h2 class="m-0 text-[1.4rem]">Recover account</h2>
|
||||
<p class="login-subtitle my-3 mb-5 text-[0.95rem] text-[var(--color-text-muted)]">Enter your username, recovery key, and a new password.</p>
|
||||
<form action="/recover" method="POST" class="login-form grid gap-4">
|
||||
<div class="form-group grid gap-1">
|
||||
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Enter your username, recovery key, and a new password.</p>
|
||||
<form action="/recover" method="POST" class="grid gap-4">
|
||||
<div class="grid gap-1">
|
||||
<label for="username">Username / Email</label>
|
||||
<input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
|
||||
<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 }/>
|
||||
</div>
|
||||
<div class="form-group grid gap-1">
|
||||
<div class="grid gap-1">
|
||||
<label for="recovery_key">Recovery key</label>
|
||||
<input type="text" id="recovery_key" name="recovery_key" required value={ recoveryKey }/>
|
||||
<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 }/>
|
||||
</div>
|
||||
<div class="form-group grid gap-1">
|
||||
<div class="grid gap-1">
|
||||
<label for="new_password">New password</label>
|
||||
<input type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
|
||||
<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"/>
|
||||
</div>
|
||||
<button type="submit" class="login-button h-10 border-0 bg-[var(--color-accent)] text-[var(--text-on-accent)] text-[0.9rem] font-semibold cursor-pointer hover:brightness-95">Reset password</button>
|
||||
<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>
|
||||
if formError != "" {
|
||||
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p>
|
||||
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p>
|
||||
}
|
||||
</form>
|
||||
<p class="auth-switch-row mt-5 mb-0 text-center text-[0.9rem] text-[var(--color-text-muted)]">
|
||||
Remembered your password? <a href="/login">Sign in</a>
|
||||
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
|
||||
Remembered your password? <a class="text-[var(--accent)]" href="/login">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,18 +116,18 @@ templ Recover(formError string, username string, recoveryKey string) {
|
||||
|
||||
templ RecoveryComplete(newRecoveryKey string) {
|
||||
@Layout("Recovery complete", false) {
|
||||
<div class="auth-shell">
|
||||
<div class="login-container">
|
||||
<div class="w-full max-w-[560px]">
|
||||
<div class="mx-auto w-full bg-[var(--panel)] p-6">
|
||||
<h2>Account recovered</h2>
|
||||
<p class="login-subtitle">Your password was reset and your recovery key was rotated.</p>
|
||||
<div class="recovery-key-row">
|
||||
<p class="recovery-key-box" id="recovery-complete-key">{ newRecoveryKey }</p>
|
||||
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button>
|
||||
<p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Your password was reset and your recovery key was rotated.</p>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<p class="auth-password-note recovery-copy-feedback" id="recovery-complete-feedback" aria-live="polite"></p>
|
||||
<p class="auth-password-note">Replace your old recovery key with this one.</p>
|
||||
<p class="auth-switch-row">
|
||||
<a href="/login" class="auth-primary-link">Go to login</a>
|
||||
<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="m-0 text-[0.75rem] leading-[1.4] text-[var(--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)]">
|
||||
<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>
|
||||
</p>
|
||||
</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) {
|
||||
@Layout("Account", true) {
|
||||
<div class="account-page">
|
||||
<section class="account-card">
|
||||
<div class="mx-auto grid w-[min(720px,100%)] gap-4">
|
||||
<section class="grid gap-3 bg-[var(--panel)] p-5">
|
||||
<h2>Account</h2>
|
||||
<div class="account-meta">
|
||||
<div class="account-meta-row">
|
||||
<span class="account-meta-label">Email / Username</span>
|
||||
<span class="account-meta-value">{ username }</span>
|
||||
<div class="grid gap-2">
|
||||
<div class="grid gap-1">
|
||||
<span class="text-[0.78rem] text-[var(--text-faint)]">Email / Username</span>
|
||||
<span class="text-[0.95rem] text-[var(--text)]">{ username }</span>
|
||||
</div>
|
||||
<div class="account-meta-row">
|
||||
<span class="account-meta-label">Created</span>
|
||||
<span class="account-meta-value">{ createdAt }</span>
|
||||
<div class="grid gap-1">
|
||||
<span class="text-[0.78rem] text-[var(--text-faint)]">Created</span>
|
||||
<span class="text-[0.95rem] text-[var(--text)]">{ createdAt }</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
<section class="grid gap-3 bg-[var(--panel)] p-5">
|
||||
<h3>Change password</h3>
|
||||
<form action="/account/password" method="POST" class="account-form" onsubmit="return confirmDangerAction('Change your password now?')">
|
||||
<div class="form-group">
|
||||
<form action="/account/password" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Change your password now?')">
|
||||
<div class="grid gap-1">
|
||||
<label for="current_password">Current password</label>
|
||||
<input type="password" id="current_password" name="current_password" required/>
|
||||
<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/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="grid gap-1">
|
||||
<label for="new_password">New password</label>
|
||||
<input type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
|
||||
<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"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="grid gap-1">
|
||||
<label for="confirm_new_password">Confirm new password</label>
|
||||
<input type="password" id="confirm_new_password" name="confirm_new_password" required/>
|
||||
<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/>
|
||||
</div>
|
||||
<button type="submit" class="account-submit-btn">Update password</button>
|
||||
<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>
|
||||
if passwordError != "" {
|
||||
<p class="auth-form-error" role="alert" aria-live="polite">{ passwordError }</p>
|
||||
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ passwordError }</p>
|
||||
}
|
||||
if passwordSuccess != "" {
|
||||
<p class="account-success" role="status" aria-live="polite">{ passwordSuccess }</p>
|
||||
<p class="m-0 text-[0.82rem] text-[var(--accent)]" role="status" aria-live="polite">{ passwordSuccess }</p>
|
||||
}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
<section class="grid gap-3 bg-[var(--panel)] p-5">
|
||||
<h3>Recovery key</h3>
|
||||
<p class="auth-password-note">To view a new recovery key, confirm your current password. This rotates your old key.</p>
|
||||
<form action="/account/recovery-key" method="POST" class="account-form" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<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">
|
||||
<label for="recovery_password">Current password</label>
|
||||
<input type="password" id="recovery_password" name="password" required/>
|
||||
<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/>
|
||||
</div>
|
||||
<button type="submit" class="account-submit-btn">Show new recovery key</button>
|
||||
<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>
|
||||
if recoveryError != "" {
|
||||
<p class="auth-form-error" role="alert" aria-live="polite">{ recoveryError }</p>
|
||||
<p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ recoveryError }</p>
|
||||
}
|
||||
if recoverySuccess != "" {
|
||||
<p class="account-success" role="status" aria-live="polite">{ recoverySuccess }</p>
|
||||
<p class="m-0 text-[0.82rem] text-[var(--accent)]" role="status" aria-live="polite">{ recoverySuccess }</p>
|
||||
}
|
||||
</form>
|
||||
if recoveryKey != "" {
|
||||
<div class="recovery-key-row">
|
||||
<p class="recovery-key-box" id="account-recovery-key">{ recoveryKey }</p>
|
||||
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<p class="auth-password-note recovery-copy-feedback" id="account-copy-feedback" aria-live="polite"></p>
|
||||
<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>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
<section class="grid gap-3 bg-[var(--panel)] p-5">
|
||||
<h3>Danger zone</h3>
|
||||
<form action="/logout" method="POST" class="account-form-inline" onsubmit="return confirmDangerAction('Log out of this account now?')">
|
||||
<button type="submit" class="account-logout-btn">Log out</button>
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import "fmt"
|
||||
|
||||
templ Catalog() {
|
||||
@Layout("mal - catalog", true) {
|
||||
<div class="catalog-grid" id="catalog-content">
|
||||
<div class="grid-full-width" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
|
||||
<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="col-span-full" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
|
||||
@ui.LoadingIndicator("Loading catalog")
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,9 +20,9 @@ templ CatalogItems(animes []jikan.Anime, nextPage int, hasNext bool) {
|
||||
|
||||
templ CatalogPlaceholderItems(count int) {
|
||||
for i := 0; i < count; i++ {
|
||||
<div class="catalog-item catalog-placeholder" aria-hidden="true">
|
||||
<div class="catalog-placeholder-thumb"></div>
|
||||
<div class="catalog-placeholder-title"></div>
|
||||
<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="mt-2 h-[0.9rem] w-4/5 animate-pulse bg-[var(--surface-search)]"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import "fmt"
|
||||
|
||||
templ Discover() {
|
||||
@Layout("mal - discover", true) {
|
||||
<div class="discover-container">
|
||||
<div class="discover-header">
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-4">
|
||||
<h1>Discover</h1>
|
||||
<p class="discover-subtitle">Browse what's airing now and what is coming soon.</p>
|
||||
<p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Browse what's airing now and what is coming soon.</p>
|
||||
</div>
|
||||
<div class="tabs discover-tabs" data-tab-group="discover">
|
||||
<div class="flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1" data-tab-group="discover">
|
||||
<button
|
||||
class="tab active"
|
||||
class="tab-trigger shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)]"
|
||||
type="button"
|
||||
hx-get="/api/discover/airing?page=1"
|
||||
hx-target="#discover-content"
|
||||
@@ -23,7 +23,7 @@ templ Discover() {
|
||||
airing now
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
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)]"
|
||||
type="button"
|
||||
hx-get="/api/discover/upcoming?page=1"
|
||||
hx-target="#discover-content"
|
||||
@@ -33,8 +33,8 @@ templ Discover() {
|
||||
upcoming
|
||||
</button>
|
||||
</div>
|
||||
<div class="catalog-grid" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
|
||||
<div class="grid-full-width">
|
||||
<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="col-span-full">
|
||||
@ui.LoadingIndicator("Loading discover")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
templ Search(q string) {
|
||||
@Layout("mal - search", true) {
|
||||
if q != "" {
|
||||
<div id="loading" class="htmx-indicator">
|
||||
<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>
|
||||
@@ -28,7 +28,7 @@ templ SearchResultsWrapper(query string, animes []jikan.Anime, nextPage int, has
|
||||
Try a different search term.
|
||||
}
|
||||
} else {
|
||||
<div class="catalog-grid">
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
|
||||
@SearchItems(query, animes, nextPage, hasNext)
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ templ Layout(title string, showHeader bool) {
|
||||
<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="/static/css/style.css"/>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css"/>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
|
||||
<script src="/static/js/discover.js" defer></script>
|
||||
@@ -18,32 +17,35 @@ templ Layout(title string, showHeader bool) {
|
||||
<script src="/static/js/timezone.js" defer></script>
|
||||
<script src="/static/js/auth.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)] font-[var(--font)] text-[14px] leading-[1.45]">
|
||||
if showHeader {
|
||||
<header>
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<a href="/" class="logo" aria-label="mal logo">
|
||||
@icons.LogoIcon("logo-svg")
|
||||
<header class="sticky top-0 z-[100] bg-[var(--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="flex min-w-0 items-center gap-5 max-[860px]:w-full max-[860px]:flex-wrap max-[860px]:gap-3" data-search-root>
|
||||
<a href="/" class="inline-flex items-center text-[var(--accent)]" aria-label="mal logo">
|
||||
@icons.LogoIcon("h-7 w-7")
|
||||
</a>
|
||||
<div class="nav">
|
||||
<a href="/">Catalog</a>
|
||||
<a href="/discover">Discover</a>
|
||||
<a href="/notifications">Notifications</a>
|
||||
<a href="/watchlist">Watchlist</a>
|
||||
<a href="/account">Account</a>
|
||||
<div class="flex flex-wrap gap-3 text-[0.85rem] max-[860px]:w-full max-[860px]:gap-2">
|
||||
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--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-[var(--text-muted)] no-underline hover:text-[var(--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-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/account">Account</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-search-wrapper">
|
||||
<form action="/search" method="GET" class="header-search" id="search-form">
|
||||
<input type="text" id="search-input" name="q" class="search-input" placeholder="Search anime..." autocomplete="off"/>
|
||||
<div id="search-dropdown" class="search-dropdown"></div>
|
||||
<div class="relative ml-auto min-w-[240px] w-[min(420px,45vw)] max-[860px]:ml-0 max-[860px]:w-full" data-search-root>
|
||||
<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"/>
|
||||
<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)]"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
<main class={ "main-content", templ.KV("auth-main", !showHeader) }>
|
||||
<main class={
|
||||
"mx-auto w-full max-w-[1580px] px-4 pt-5 pb-8 max-[860px]:px-3 max-[860px]:pb-6",
|
||||
templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader),
|
||||
}>
|
||||
{ children... }
|
||||
</main>
|
||||
<script src="/static/js/search.js"></script>
|
||||
|
||||
@@ -2,11 +2,11 @@ package templates
|
||||
|
||||
templ NotFoundPage() {
|
||||
@Layout("mal - not found", false) {
|
||||
<section class="not-found-page anime-surface 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">
|
||||
<p class="not-found-code m-0 text-[clamp(4rem,15vw,10rem)] tracking-[0.04em] leading-[0.9] text-[var(--color-text-muted)]">404</p>
|
||||
<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">
|
||||
<p class="m-0 text-[clamp(4rem,15vw,10rem)] tracking-[0.04em] leading-[0.9] text-[var(--text-muted)]">404</p>
|
||||
<h1 class="m-0 text-[clamp(2rem,4vw,3rem)]">Page not found</h1>
|
||||
<p class="empty-inline-note text-[var(--color-text-muted)]">The page you requested does not exist, or it was moved.</p>
|
||||
<p><a href="/" class="not-found-link text-[var(--color-accent)] no-underline text-[1.05rem] hover:underline">Back to catalog</a></p>
|
||||
<p class="text-[var(--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>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ type WatchingAnimeWithDetails struct {
|
||||
|
||||
templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
|
||||
@Layout("mal - notifications", true) {
|
||||
<div class="notifications-page">
|
||||
<div class="grid gap-4">
|
||||
<h1>Notifications</h1>
|
||||
<div class="status-tabs">
|
||||
<a href="/notifications?tab=tracking" class={ activeClass(activeTab == "tracking") }>Tracking</a>
|
||||
<a href="/notifications?tab=sequels" class={ activeClass(activeTab == "sequels") }>Sequels</a>
|
||||
<div class="mb-3 flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1">
|
||||
<a href="/notifications?tab=tracking" class={ statusTabClass(activeTab == "tracking") }>Tracking</a>
|
||||
<a href="/notifications?tab=sequels" class={ statusTabClass(activeTab == "sequels") }>Sequels</a>
|
||||
</div>
|
||||
|
||||
if activeTab == "sequels" {
|
||||
@@ -25,13 +25,13 @@ templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
|
||||
@ui.LoadingIndicator("Syncing sequel graphs...")
|
||||
</div>
|
||||
} else {
|
||||
<p class="notifications-subtitle">Shows you're currently watching or planning to watch.</p>
|
||||
<p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Shows you're currently watching or planning to watch.</p>
|
||||
if len(watching) == 0 {
|
||||
@ui.EmptyState("No airing anime in your watching list.") {
|
||||
<span class="empty-state-hint">Add currently airing shows to your watching list to see upcoming episodes here.</span>
|
||||
<span class="text-[0.9rem] text-[var(--text-muted)]">Add currently airing shows to your watching list to see upcoming episodes here.</span>
|
||||
}
|
||||
} else {
|
||||
<div class="notifications-list">
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
|
||||
for _, item := range watching {
|
||||
@NotificationCard(item)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func splitUpcomingSeasons(items []database.GetUpcomingSeasonsRow) (airing []data
|
||||
templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
|
||||
if len(upcomingSeasons) == 0 {
|
||||
@ui.EmptyState("No upcoming seasons for anime you've watched.") {
|
||||
<span class="empty-state-hint">As you watch more shows, new seasons will appear here.</span>
|
||||
<span class="text-[0.9rem] text-[var(--text-muted)]">As you watch more shows, new seasons will appear here.</span>
|
||||
}
|
||||
} else {
|
||||
@renderSplitSeasons(upcomingSeasons)
|
||||
@@ -66,10 +66,10 @@ templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
|
||||
templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
|
||||
if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true {
|
||||
if len(airing) > 0 {
|
||||
<section class="notifications-group notifications-list-spaced">
|
||||
<h2 class="notifications-group-title">Airing now</h2>
|
||||
<p class="notifications-group-note">These are the currently airing anime, but you're not tracking any of these.</p>
|
||||
<div class="notifications-list">
|
||||
<section class="mb-4 grid gap-3">
|
||||
<h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">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>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
|
||||
for _, item := range airing {
|
||||
@UpcomingSeasonCard(item)
|
||||
}
|
||||
@@ -78,10 +78,10 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
|
||||
}
|
||||
|
||||
if len(upcoming) > 0 {
|
||||
<section class="notifications-group">
|
||||
<h2 class="notifications-group-title">Announced & upcoming</h2>
|
||||
<p class="notifications-group-note">Newly announced or upcoming seasons related to anime you've watched.</p>
|
||||
<div class="notifications-list">
|
||||
<section class="grid gap-3">
|
||||
<h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">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>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
|
||||
for _, item := range upcoming {
|
||||
@UpcomingSeasonCard(item)
|
||||
}
|
||||
@@ -96,19 +96,19 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
|
||||
ID: int(item.ID),
|
||||
Title: displaySeasonTitle(item),
|
||||
ImageURL: item.ImageUrl,
|
||||
Class: "notification-card",
|
||||
ImgClass: "notification-image",
|
||||
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
|
||||
ImgClass: "flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden",
|
||||
}) {
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">
|
||||
<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)]">
|
||||
{ displaySeasonTitle(item) }
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if item.Status.Valid {
|
||||
<span class="notification-muted">{ seasonStatusLabel(item.Status.String) }</span>
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">{ seasonStatusLabel(item.Status.String) }</span>
|
||||
}
|
||||
if strings.TrimSpace(item.PrequelTitle) != "" {
|
||||
<span class="notification-muted">{ fmt.Sprintf("Sequel to %s", item.PrequelTitle) }</span>
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">{ fmt.Sprintf("Sequel to %s", item.PrequelTitle) }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,20 +124,20 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
|
||||
ID: int(item.Entry.AnimeID),
|
||||
Title: displayTitle(item.Entry),
|
||||
ImageURL: item.Entry.ImageUrl,
|
||||
Class: "notification-card",
|
||||
ImgClass: "notification-image",
|
||||
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
|
||||
ImgClass: "flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden",
|
||||
}) {
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">
|
||||
<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)]">
|
||||
{ displayTitle(item.Entry) }
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if item.Anime.Broadcast.String != "" {
|
||||
<span class="notification-broadcast" 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="notification-next-airing" data-next-airing="pending">Calculating next episode time...</span>
|
||||
<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-[0.67rem] text-[var(--text-faint)]" data-next-airing="pending">Calculating next episode time...</span>
|
||||
}
|
||||
if item.Anime.Episodes > 0 {
|
||||
<span class="notification-progress">
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">
|
||||
if item.Entry.CurrentEpisode.Valid {
|
||||
{ fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) }
|
||||
} else {
|
||||
@@ -145,7 +145,7 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
|
||||
}
|
||||
</span>
|
||||
} else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 {
|
||||
<span class="notification-progress">
|
||||
<span class="text-[0.67rem] text-[var(--text-faint)]">
|
||||
{ fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64) }
|
||||
</span>
|
||||
}
|
||||
@@ -174,3 +174,11 @@ func seasonStatusLabel(status string) string {
|
||||
|
||||
return statusText
|
||||
}
|
||||
|
||||
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"
|
||||
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 base
|
||||
}
|
||||
|
||||
@@ -8,31 +8,31 @@ import (
|
||||
|
||||
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
|
||||
@Layout("My Watchlist", true) {
|
||||
<div class="watchlist-header">
|
||||
<div class="watchlist-heading">
|
||||
<div class="mb-4 flex items-end justify-between gap-4 max-[860px]:flex-col max-[860px]:items-start">
|
||||
<div class="grid gap-1">
|
||||
<h2>Watchlist</h2>
|
||||
<p class="watchlist-subtitle">Track what you're watching with less noise.</p>
|
||||
<p class="m-0 text-[0.86rem] text-[var(--text-muted)]">Track what you're watching with less noise.</p>
|
||||
</div>
|
||||
<div class="watchlist-controls">
|
||||
<a href="/api/watchlist/export" class="text-link">Export</a>
|
||||
<button class="text-link" 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="is-hidden">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 max-[860px]:w-full max-[860px]: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>
|
||||
<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>
|
||||
<form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="hidden">
|
||||
<input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/>
|
||||
</form>
|
||||
<div class="view-toggle">
|
||||
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ activeClass(layout == "grid") }>Grid</a>
|
||||
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ activeClass(layout == "table") }>Table</a>
|
||||
<div class="flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1">
|
||||
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "grid") }>Grid</a>
|
||||
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-tabs">
|
||||
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ activeClass(currentStatus == "all") }>All</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ activeClass(currentStatus == "watching") }>Watching</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "continuing", sortBy, sortOrder)) } class={ activeClass(currentStatus == "continuing") }>Continuing</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "on_hold", sortBy, sortOrder)) } class={ activeClass(currentStatus == "on_hold") }>On hold</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "plan_to_watch", sortBy, sortOrder)) } class={ activeClass(currentStatus == "plan_to_watch") }>Plan to watch</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "dropped", sortBy, sortOrder)) } class={ activeClass(currentStatus == "dropped") }>Dropped</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "completed", sortBy, sortOrder)) } class={ activeClass(currentStatus == "completed") }>Completed</a>
|
||||
<div class="mb-3 flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1">
|
||||
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "continuing", sortBy, sortOrder)) } class={ tabClass(currentStatus == "continuing") }>Continuing</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "on_hold", sortBy, sortOrder)) } class={ tabClass(currentStatus == "on_hold") }>On hold</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "plan_to_watch", sortBy, sortOrder)) } class={ tabClass(currentStatus == "plan_to_watch") }>Plan to watch</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "dropped", sortBy, sortOrder)) } class={ tabClass(currentStatus == "dropped") }>Dropped</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "completed", sortBy, sortOrder)) } class={ tabClass(currentStatus == "completed") }>Completed</a>
|
||||
</div>
|
||||
@ui.SortFilter(ui.SortFilterOptions{Sort: sortBy, Order: sortOrder, View: layout, Status: currentStatus})
|
||||
if len(entries) == 0 {
|
||||
@@ -47,16 +47,16 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
||||
}
|
||||
} else {
|
||||
if layout == "grid" {
|
||||
<div class="catalog-grid">
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
|
||||
for _, entry := range entries {
|
||||
<div class="catalog-item watchlist-item" 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{
|
||||
ID: int(entry.AnimeID),
|
||||
Title: entry.DisplayTitle(),
|
||||
ImageURL: entry.ImageUrl,
|
||||
})
|
||||
<button
|
||||
class="remove-btn"
|
||||
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)]"
|
||||
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"
|
||||
@@ -65,30 +65,30 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<table class="watchlist-table">
|
||||
<table class="block w-full overflow-x-auto whitespace-nowrap bg-[var(--panel)] md:table md:overflow-visible md:whitespace-normal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<th></th>
|
||||
<th class="p-[0.6rem]"></th>
|
||||
<th class="p-[0.6rem] text-left text-[0.67rem] text-[var(--text-faint)]">Title</th>
|
||||
<th class="p-[0.6rem]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, entry := range entries {
|
||||
<tr id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||
<td>
|
||||
<tr class="hover:bg-[var(--panel-soft)]" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||
<td class="p-[0.6rem]">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
|
||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="thumb" loading="lazy"/>
|
||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-[2/3] w-9 object-cover" loading="lazy"/>
|
||||
</a>
|
||||
</td>
|
||||
<td class="title-cell">
|
||||
<td class="p-[0.6rem] font-medium">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
|
||||
{ entry.DisplayTitle() }
|
||||
</a>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<td class="w-[90px] p-[0.6rem]">
|
||||
<button
|
||||
class="remove-link"
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-[0.8rem] text-[var(--text-muted)] hover:text-[var(--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"
|
||||
@@ -103,11 +103,12 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
||||
}
|
||||
}
|
||||
|
||||
func activeClass(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"
|
||||
if active {
|
||||
return "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 ""
|
||||
return base
|
||||
}
|
||||
|
||||
func watchlistURL(view string, status string, sortBy string, sortOrder string) string {
|
||||
|
||||
1285
static/css/style.css
1285
static/css/style.css
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@
|
||||
return;
|
||||
}
|
||||
dropdown.classList.toggle("open");
|
||||
const menu = dropdown.querySelector("[data-dropdown-menu]");
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.classList.toggle("invisible");
|
||||
menu.classList.toggle("opacity-0");
|
||||
}
|
||||
};
|
||||
window.toggleDropdown = toggleDropdown;
|
||||
document.addEventListener("click", (event) => {
|
||||
@@ -19,6 +24,11 @@
|
||||
}
|
||||
if (!dropdown.contains(target)) {
|
||||
dropdown.classList.remove("open");
|
||||
const menu = dropdown.querySelector("[data-dropdown-menu]");
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.classList.add("invisible");
|
||||
menu.classList.add("opacity-0");
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
}
|
||||
|
||||
dropdown.classList.toggle('open')
|
||||
const menu = dropdown.querySelector('[data-dropdown-menu]')
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.classList.toggle('invisible')
|
||||
menu.classList.toggle('opacity-0')
|
||||
}
|
||||
}
|
||||
|
||||
;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleDropdown
|
||||
@@ -23,6 +28,11 @@
|
||||
|
||||
if (!dropdown.contains(target)) {
|
||||
dropdown.classList.remove('open')
|
||||
const menu = dropdown.querySelector('[data-dropdown-menu]')
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.classList.add('invisible')
|
||||
menu.classList.add('opacity-0')
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
return;
|
||||
}
|
||||
const triggers = group.querySelectorAll("[data-tab-trigger]");
|
||||
triggers.forEach((tab) => tab.classList.remove("active"));
|
||||
clickedTab.classList.add("active");
|
||||
triggers.forEach((tab) => {
|
||||
tab.classList.add("tab-trigger");
|
||||
tab.classList.remove("bg-[var(--surface-tab-active)]", "text-[var(--accent)]");
|
||||
tab.classList.add("bg-[var(--panel-soft)]", "text-[var(--text-muted)]");
|
||||
});
|
||||
clickedTab.classList.add("tab-trigger");
|
||||
clickedTab.classList.remove("bg-[var(--panel-soft)]", "text-[var(--text-muted)]");
|
||||
clickedTab.classList.add("bg-[var(--surface-tab-active)]", "text-[var(--accent)]");
|
||||
};
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
}
|
||||
|
||||
const triggers = group.querySelectorAll('[data-tab-trigger]')
|
||||
triggers.forEach((tab: Element): void => tab.classList.remove('active'))
|
||||
clickedTab.classList.add('active')
|
||||
triggers.forEach((tab: Element): void => {
|
||||
tab.classList.add('tab-trigger')
|
||||
tab.classList.remove('bg-[var(--surface-tab-active)]', 'text-[var(--accent)]')
|
||||
tab.classList.add('bg-[var(--panel-soft)]', 'text-[var(--text-muted)]')
|
||||
})
|
||||
clickedTab.classList.add('tab-trigger')
|
||||
clickedTab.classList.remove('bg-[var(--panel-soft)]', 'text-[var(--text-muted)]')
|
||||
clickedTab.classList.add('bg-[var(--surface-tab-active)]', 'text-[var(--accent)]')
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event: MouseEvent): void => {
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
if (!target.closest(".header-search-wrapper")) {
|
||||
if (!target.closest("[data-search-root]")) {
|
||||
searchDropdown.replaceChildren();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (!target.closest('.header-search-wrapper')) {
|
||||
if (!target.closest('[data-search-root]')) {
|
||||
searchDropdown.replaceChildren()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
return formatter.format(date);
|
||||
};
|
||||
const updateNextAiring = (node, parsed) => {
|
||||
const card = node.closest(".notification-content");
|
||||
const card = node.closest("[data-notification-content]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +164,7 @@
|
||||
nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`;
|
||||
};
|
||||
const updateNode = (node, localOffsetMinutes) => {
|
||||
const card = node.closest(".notification-content");
|
||||
const card = node.closest("[data-notification-content]");
|
||||
const nextNode = card ? card.querySelector("[data-next-airing]") : null;
|
||||
const structured = parseFromStructuredAttrs(node);
|
||||
const source = node.getAttribute("data-jst-text");
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
}
|
||||
|
||||
const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => {
|
||||
const card = node.closest('.notification-content')
|
||||
const card = node.closest('[data-notification-content]')
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
@@ -210,7 +210,7 @@
|
||||
}
|
||||
|
||||
const updateNode = (node: Element, localOffsetMinutes: number): void => {
|
||||
const card = node.closest('.notification-content')
|
||||
const card = node.closest('[data-notification-content]')
|
||||
const nextNode = card ? card.querySelector('[data-next-airing]') : null
|
||||
|
||||
const structured = parseFromStructuredAttrs(node)
|
||||
|
||||
Reference in New Issue
Block a user