ui: declutter anime pages and controls

This commit is contained in:
2026-04-10 01:15:19 +02:00
parent 8965dc5441
commit e2fc44bf1a
12 changed files with 425 additions and 168 deletions

View File

@@ -12,7 +12,7 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
</div>
}
if hasNext {
<div class="scroll-trigger" style="grid-column: 1 / -1; height: 20px;" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
<div class="scroll-trigger full-span-trigger" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
}
<script data-container={ containerID }>
(function() {

View File

@@ -24,8 +24,12 @@ templ SortFilter(opts SortFilterOptions) {
<option value="asc" selected?={ opts.Order == "asc" }>Ascending</option>
</select>
</div>
<div class="sort-filter-group density-group" aria-hidden="true">
<span class="density-label">Density</span>
<button type="button" class="density-toggle" id="density-toggle" onclick="document.body.classList.toggle('density-compact')">Compact</button>
</div>
</div>
<form id="sort-form" method="get" style="display: none;">
<form id="sort-form" method="get" class="is-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 != "" {

View File

@@ -9,7 +9,7 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
@Layout("mal - " + anime.DisplayTitle()) {
<div class="anime-page">
<div class="anime-main">
<div class="anime-hero">
<div class="anime-hero anime-surface">
<div class="anime-poster">
if anime.ImageURL() != "" {
<img src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
@@ -41,7 +41,7 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
</div>
</div>
</div>
<section class="anime-synopsis">
<section class="anime-synopsis anime-surface anime-section">
<h3>Synopsis</h3>
if anime.Synopsis != "" {
<p>{ anime.Synopsis }</p>
@@ -49,65 +49,56 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
<p class="no-synopsis">No synopsis available.</p>
}
</section>
<section class="anime-relations">
<section class="anime-relations anime-surface anime-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">
<section class="anime-recommendations anime-surface anime-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">
if anime.TitleJapanese != "" {
<div class="sidebar-row">
<span class="sidebar-label">Japanese</span>
<span class="sidebar-value">{ 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>
}
if anime.Aired.String != "" {
<div class="sidebar-row">
<span class="sidebar-label">Aired</span>
<span class="sidebar-value">{ 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>
}
if anime.Duration != "" {
<div class="sidebar-row">
<span class="sidebar-label">Duration</span>
<span class="sidebar-value">{ anime.Duration }</span>
</div>
}
if anime.Status != "" {
<div class="sidebar-row">
<span class="sidebar-label">Status</span>
<span class="sidebar-value">{ anime.Status }</span>
</div>
}
if anime.Score > 0 {
<div class="sidebar-row">
<span class="sidebar-label">MAL Score</span>
<span class="sidebar-value">{ fmt.Sprintf("%.2f", anime.Score) }</span>
</div>
}
<aside class="anime-sidebar anime-surface">
<div class="anime-side-section">
<h3>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>
}
if anime.Premiered() != "" {
<div class="sidebar-row">
<span class="sidebar-label">Premiered</span>
<span class="sidebar-value">{ 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>
}
if anime.Score > 0 {
<div class="sidebar-row">
<span class="sidebar-label">MAL Score</span>
<span class="sidebar-value">{ fmt.Sprintf("%.2f", anime.Score) }</span>
</div>
}
if anime.Duration != "" {
<div class="sidebar-row">
<span class="sidebar-label">Duration</span>
<span class="sidebar-value">{ anime.Duration }</span>
</div>
}
</div>
if len(anime.Genres) > 0 {
<div class="sidebar-row sidebar-row-wrap">
<span class="sidebar-label">Genres</span>
<div class="anime-side-section">
<h3>Genres</h3>
<div class="sidebar-tags">
for _, g := range anime.Genres {
<span class="sidebar-tag">{ g.Name }</span>
@@ -115,25 +106,40 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
</div>
</div>
}
if len(anime.Studios) > 0 {
if hasExtraSidebarDetails(anime) {
<details class="anime-side-section side-details-more">
<summary>More metadata</summary>
if anime.TitleJapanese != "" {
<div class="sidebar-row">
<span class="sidebar-label">Japanese</span>
<span class="sidebar-value">{ 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>
}
if len(anime.Studios) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Studios</span>
<span class="sidebar-value">{ joinNames(anime.Studios) }</span>
</div>
}
if len(anime.Producers) > 0 {
}
if len(anime.Producers) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Producers</span>
<span class="sidebar-value">{ joinNames(anime.Producers) }</span>
</div>
}
if anime.Source != "" {
}
if anime.Source != "" {
<div class="sidebar-row">
<span class="sidebar-label">Source</span>
<span class="sidebar-value">{ anime.Source }</span>
</div>
}
if len(anime.Demographics) > 0 {
}
if len(anime.Demographics) > 0 {
<div class="sidebar-row sidebar-row-wrap">
<span class="sidebar-label">Demographics</span>
<div class="sidebar-tags">
@@ -142,8 +148,8 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
}
</div>
</div>
}
if len(anime.Themes) > 0 {
}
if len(anime.Themes) > 0 {
<div class="sidebar-row sidebar-row-wrap">
<span class="sidebar-label">Themes</span>
<div class="sidebar-tags">
@@ -152,14 +158,14 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
}
</div>
</div>
}
if anime.Broadcast.String != "" {
}
if anime.Broadcast.String != "" {
<div class="sidebar-row">
<span class="sidebar-label">Broadcast</span>
<span class="sidebar-value">{ anime.Broadcast.String }</span>
</div>
}
if len(anime.Streaming) > 0 {
}
if len(anime.Streaming) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Streaming</span>
<div class="sidebar-value">
@@ -168,20 +174,11 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
}
</div>
</div>
}
</details>
}
</aside>
</div>
<script>
function toggleDropdown() {
document.getElementById('watchlist-dropdown').classList.toggle('open');
}
document.addEventListener('click', function(e) {
const dropdown = document.getElementById('watchlist-dropdown');
if (dropdown && !dropdown.contains(e.target)) {
dropdown.classList.remove('open');
}
});
</script>
}
}
@@ -285,7 +282,7 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
}
</div>
} else {
<p style="color: var(--text-muted); font-size: var(--text-sm);">No related anime found.</p>
<p class="empty-inline-note">No related anime found.</p>
}
}
@@ -304,6 +301,10 @@ templ AnimeRecommendations(recs []jikan.Anime) {
}
</div>
} else {
<p style="color: var(--text-muted); font-size: var(--text-sm);">No recommendations available.</p>
<p class="empty-inline-note">No recommendations available.</p>
}
}
func hasExtraSidebarDetails(anime jikan.Anime) bool {
return anime.TitleJapanese != "" || len(anime.TitleSynonyms) > 0 || len(anime.Studios) > 0 || len(anime.Producers) > 0 || anime.Source != "" || len(anime.Demographics) > 0 || len(anime.Themes) > 0 || anime.Broadcast.String != "" || len(anime.Streaming) > 0
}

View File

@@ -7,28 +7,34 @@ import "fmt"
templ Discover() {
@Layout("mal - discover") {
<div class="discover-container">
<div class="tabs">
<div class="discover-header">
<h1>Discover</h1>
<p class="discover-subtitle">Browse what's airing now and what is coming soon.</p>
</div>
<div class="tabs discover-tabs" data-tab-group="discover">
<button
class="tab active"
type="button"
hx-get="/api/discover/airing?page=1"
hx-target="#discover-content"
hx-trigger="click"
onclick="document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); this.classList.add('active');"
data-tab-trigger
>
airing now
</button>
<button
class="tab"
type="button"
hx-get="/api/discover/upcoming?page=1"
hx-target="#discover-content"
hx-trigger="click"
onclick="document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); this.classList.add('active');"
data-tab-trigger
>
upcoming
</button>
</div>
<div class="catalog-grid" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
<div style="grid-column: 1 / -1;">
<div class="grid-full-width">
@ui.LoadingIndicator("Loading discover")
</div>
</div>

View File

@@ -12,6 +12,9 @@ templ Layout(title string) {
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/css/style.css"/>
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<script src="/static/js/discover.js" defer></script>
<script src="/static/js/schedule.js" defer></script>
<script src="/static/js/anime.js" defer></script>
</head>
<body>
<header>

View File

@@ -18,7 +18,7 @@ templ Notifications(watching []WatchingAnimeWithDetails) {
if len(watching) == 0 {
@ui.EmptyState("No airing anime in your watching list.") {
<span style="font-size: var(--text-sm); margin-top: var(--space-sm); display: block;">Add currently airing shows to your watching list to see upcoming episodes here.</span>
<span class="empty-state-hint">Add currently airing shows to your watching list to see upcoming episodes here.</span>
}
} else {
<div class="notifications-list">
@@ -28,7 +28,7 @@ templ Notifications(watching []WatchingAnimeWithDetails) {
</div>
}
<h1 style="margin-top: var(--space-2xl);">Discovered sequels</h1>
<h1 class="notifications-section-title">Discovered sequels</h1>
<p class="notifications-subtitle">Because you've watched prequels.</p>
<div hx-get="/notifications/upcoming" hx-trigger="load, every 15s" hx-swap="innerHTML">
@@ -52,7 +52,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 style="font-size: var(--text-sm); margin-top: var(--space-sm); display: block;">As you watch more shows, new seasons will appear here.</span>
<span class="empty-state-hint">As you watch more shows, new seasons will appear here.</span>
}
} else {
@renderSplitSeasons(upcomingSeasons)
@@ -62,8 +62,8 @@ templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true {
if len(airing) > 0 {
<h2 style="font-size: var(--text-md); margin-bottom: var(--space-md); color: var(--text-muted);">Airing now (not tracked)</h2>
<div class="notifications-list" style="margin-bottom: var(--space-xl);">
<h2 class="notifications-group-title">Airing now (not tracked)</h2>
<div class="notifications-list notifications-list-spaced">
for _, item := range airing {
@UpcomingSeasonCard(item)
}
@@ -71,7 +71,7 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
}
if len(upcoming) > 0 {
<h2 style="font-size: var(--text-md); margin-bottom: var(--space-md); color: var(--text-muted);">Announced & upcoming</h2>
<h2 class="notifications-group-title">Announced & upcoming</h2>
<div class="notifications-list">
for _, item := range upcoming {
@UpcomingSeasonCard(item)
@@ -94,7 +94,7 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
{ displaySeasonTitle(item) }
</div>
<div class="notification-meta">
<span class="notification-broadcast" style="color: var(--text-muted) !important;">Because you watched { item.PrequelTitle }</span>
<span class="notification-broadcast notification-muted">Because you watched { item.PrequelTitle }</span>
</div>
</div>
}

View File

@@ -11,28 +11,20 @@ templ Schedule() {
<h1>Weekly schedule</h1>
<p class="schedule-subtitle">Airing times in JST</p>
<div class="schedule-tabs">
<button class="schedule-tab active" data-day="Monday" onclick="loadDay('monday', this)">Mon</button>
<button class="schedule-tab" data-day="Tuesday" onclick="loadDay('tuesday', this)">Tue</button>
<button class="schedule-tab" data-day="Wednesday" onclick="loadDay('wednesday', this)">Wed</button>
<button class="schedule-tab" data-day="Thursday" onclick="loadDay('thursday', this)">Thu</button>
<button class="schedule-tab" data-day="Friday" onclick="loadDay('friday', this)">Fri</button>
<button class="schedule-tab" data-day="Saturday" onclick="loadDay('saturday', this)">Sat</button>
<button class="schedule-tab" data-day="Sunday" onclick="loadDay('sunday', this)">Sun</button>
<div class="schedule-tabs" data-tab-group="schedule">
<button class="schedule-tab active" type="button" data-day="monday" data-schedule-tab>Mon</button>
<button class="schedule-tab" type="button" data-day="tuesday" data-schedule-tab>Tue</button>
<button class="schedule-tab" type="button" data-day="wednesday" data-schedule-tab>Wed</button>
<button class="schedule-tab" type="button" data-day="thursday" data-schedule-tab>Thu</button>
<button class="schedule-tab" type="button" data-day="friday" data-schedule-tab>Fri</button>
<button class="schedule-tab" type="button" data-day="saturday" data-schedule-tab>Sat</button>
<button class="schedule-tab" type="button" data-day="sunday" data-schedule-tab>Sun</button>
</div>
<div id="schedule-content" hx-get="/api/schedule?day=monday" hx-trigger="load">
@ui.LoadingIndicator("Loading schedule")
</div>
</div>
<script>
function loadDay(day, btn) {
document.querySelectorAll('.schedule-tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
htmx.ajax('GET', '/api/schedule?day=' + day, '#schedule-content');
}
</script>
}
}

View File

@@ -9,27 +9,30 @@ import (
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
@Layout("My Watchlist") {
<div class="watchlist-header">
<h2>Watchlist</h2>
<div class="watchlist-heading">
<h2>Watchlist</h2>
<p class="watchlist-subtitle">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" onclick="document.getElementById('import-file').click()">Import</button>
<form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" style="display: none;">
<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">
<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(fmt.Sprintf("/watchlist?view=grid&status=%s&sort=%s&order=%s", currentStatus, sortBy, sortOrder)) } class={ viewClass(layout == "grid") }>Grid</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=table&status=%s&sort=%s&order=%s", currentStatus, sortBy, sortOrder)) } class={ viewClass(layout == "table") }>Table</a>
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ viewClass(layout == "grid") }>Grid</a>
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ viewClass(layout == "table") }>Table</a>
</div>
</div>
</div>
<div class="status-tabs">
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=all&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=watching&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=continuing&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "continuing") }>Continuing</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=on_hold&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "on_hold") }>On hold</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=plan_to_watch&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "plan_to_watch") }>Plan to watch</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=dropped&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "dropped") }>Dropped</a>
<a href={ templ.URL(fmt.Sprintf("/watchlist?view=%s&status=completed&sort=%s&order=%s", layout, sortBy, sortOrder)) } class={ tabClass(currentStatus == "completed") }>Completed</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, "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 {
@@ -89,7 +92,6 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
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"
style="background: none; border: none; cursor: pointer;"
>Remove</button>
</td>
</tr>
@@ -114,3 +116,7 @@ func tabClass(active bool) string {
}
return ""
}
func watchlistURL(view string, status string, sortBy string, sortOrder string) string {
return fmt.Sprintf("/watchlist?view=%s&status=%s&sort=%s&order=%s", view, status, sortBy, sortOrder)
}