From e2fc44bf1ae2a3c528e2b299637ad62ee4c0d225 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 10 Apr 2026 01:15:19 +0200 Subject: [PATCH] ui: declutter anime pages and controls --- internal/shared/ui/anime_list.templ | 2 +- internal/shared/ui/sort_filter.templ | 6 +- internal/templates/anime.templ | 151 +++++++------- internal/templates/discovery.templ | 14 +- internal/templates/layout.templ | 3 + internal/templates/notifications.templ | 14 +- internal/templates/schedule.templ | 24 +-- internal/templates/watchlist.templ | 32 +-- static/css/style.css | 264 ++++++++++++++++++++----- static/js/anime.js | 28 +++ static/js/discover.js | 26 +++ static/js/schedule.js | 29 +++ 12 files changed, 425 insertions(+), 168 deletions(-) create mode 100644 static/js/anime.js create mode 100644 static/js/discover.js create mode 100644 static/js/schedule.js diff --git a/internal/shared/ui/anime_list.templ b/internal/shared/ui/anime_list.templ index fd81a48..6f8d4c9 100644 --- a/internal/shared/ui/anime_list.templ +++ b/internal/shared/ui/anime_list.templ @@ -12,7 +12,7 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont } if hasNext { -
+
} } } @@ -285,7 +282,7 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) { } } else { -

No related anime found.

+

No related anime found.

} } @@ -304,6 +301,10 @@ templ AnimeRecommendations(recs []jikan.Anime) { } } else { -

No recommendations available.

+

No recommendations available.

} } + +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 +} diff --git a/internal/templates/discovery.templ b/internal/templates/discovery.templ index a05ba9f..41d4c23 100644 --- a/internal/templates/discovery.templ +++ b/internal/templates/discovery.templ @@ -7,28 +7,34 @@ import "fmt" templ Discover() { @Layout("mal - discover") {
-
+
+

Discover

+

Browse what's airing now and what is coming soon.

+
+
-
+
@ui.LoadingIndicator("Loading discover")
diff --git a/internal/templates/layout.templ b/internal/templates/layout.templ index 77fd357..610684c 100644 --- a/internal/templates/layout.templ +++ b/internal/templates/layout.templ @@ -12,6 +12,9 @@ templ Layout(title string) { + + +
diff --git a/internal/templates/notifications.templ b/internal/templates/notifications.templ index 0530ada..5faae44 100644 --- a/internal/templates/notifications.templ +++ b/internal/templates/notifications.templ @@ -18,7 +18,7 @@ templ Notifications(watching []WatchingAnimeWithDetails) { if len(watching) == 0 { @ui.EmptyState("No airing anime in your watching list.") { - Add currently airing shows to your watching list to see upcoming episodes here. + Add currently airing shows to your watching list to see upcoming episodes here. } } else {
@@ -28,7 +28,7 @@ templ Notifications(watching []WatchingAnimeWithDetails) {
} -

Discovered sequels

+

Discovered sequels

Because you've watched prequels.

@@ -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.") { - As you watch more shows, new seasons will appear here. + As you watch more shows, new seasons will appear here. } } 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 { -

Airing now (not tracked)

-
+

Airing now (not tracked)

+
for _, item := range airing { @UpcomingSeasonCard(item) } @@ -71,7 +71,7 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) { } if len(upcoming) > 0 { -

Announced & upcoming

+

Announced & upcoming

for _, item := range upcoming { @UpcomingSeasonCard(item) @@ -94,7 +94,7 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) { { displaySeasonTitle(item) }
- Because you watched { item.PrequelTitle } + Because you watched { item.PrequelTitle }
} diff --git a/internal/templates/schedule.templ b/internal/templates/schedule.templ index e86c12f..5c7d35e 100644 --- a/internal/templates/schedule.templ +++ b/internal/templates/schedule.templ @@ -11,28 +11,20 @@ templ Schedule() {

Weekly schedule

Airing times in JST

-
- - - - - - - +
+ + + + + + +
@ui.LoadingIndicator("Loading schedule")
- - } } diff --git a/internal/templates/watchlist.templ b/internal/templates/watchlist.templ index 3d27524..5f6b359 100644 --- a/internal/templates/watchlist.templ +++ b/internal/templates/watchlist.templ @@ -9,27 +9,30 @@ import ( templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) { @Layout("My Watchlist") {
-

Watchlist

+
+

Watchlist

+

Track what you're watching with less noise.

+
Export - -
- Grid - Table + Grid + Table
@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 @@ -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) +} diff --git a/static/css/style.css b/static/css/style.css index 0b8068d..18fd09e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -81,8 +81,11 @@ body { background-color: var(--bg); + background-image: + radial-gradient(1100px 500px at 8% -10%, #1a1b22 0%, transparent 55%), + radial-gradient(900px 420px at 92% 0%, #171a1e 0%, transparent 60%); color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-family: "Avenir Next", "Trebuchet MS", "Gill Sans", sans-serif; font-size: var(--text-lg); line-height: 1.5; margin: 0; @@ -303,10 +306,34 @@ header { /* Main */ main { padding: var(--space-lg); - max-width: 1400px; + max-width: 1280px; margin: 0 auto; } +.is-hidden { + display: none; +} + +.grid-full-width { + grid-column: 1 / -1; +} + +.full-span-trigger { + grid-column: 1 / -1; + height: 20px; +} + +.empty-inline-note { + color: var(--text-muted); + font-size: var(--text-sm); +} + +.empty-state-hint { + font-size: var(--text-sm); + margin-top: var(--space-sm); + display: block; +} + a { color: var(--link); text-decoration: none; @@ -559,8 +586,9 @@ a.htmx-request { .watchlist-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; margin-bottom: var(--space-lg); + gap: var(--space-lg); } .watchlist-header h2 { @@ -569,11 +597,24 @@ a.htmx-request { margin: 0; } +.watchlist-heading { + display: grid; + gap: var(--space-xs); +} + +.watchlist-subtitle { + margin: 0; + color: var(--text-muted); + font-size: var(--text-sm); +} + .watchlist-controls { display: flex; align-items: center; gap: var(--space-lg); font-size: var(--text-md); + flex-wrap: wrap; + justify-content: flex-end; } .text-link { @@ -619,19 +660,19 @@ a.htmx-request { .status-tabs { display: flex; - gap: 0; + gap: var(--space-xs); margin-bottom: var(--space-lg); - border-bottom: 1px solid var(--border); + border-bottom: none; overflow-x: auto; } .status-tabs a { - padding: var(--space-md) var(--space-lg); - font-size: var(--text-base); + padding: var(--space-sm) var(--space-md); + font-size: var(--text-sm); color: var(--link); white-space: nowrap; - border-bottom: 2px solid transparent; - margin-bottom: -1px; + border: 1px solid var(--border); + background: var(--surface); text-decoration: none; } @@ -640,7 +681,8 @@ a.htmx-request { } .status-tabs a.active { - border-bottom-color: var(--link); + border-color: var(--text-muted); + background: var(--surface-hover); font-weight: 600; } @@ -743,22 +785,37 @@ a.htmx-request { text-decoration: underline; } +.watchlist-table .actions-cell { + width: 100px; +} + /* Anime Page */ .anime-page { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--sidebar-width); gap: var(--space-2xl); - max-width: 1200px; + align-items: start; } .anime-main { - flex: 1; + display: grid; + gap: var(--space-lg); min-width: 0; } .anime-hero { display: flex; gap: var(--space-xl); - margin-bottom: var(--space-2xl); +} + +.anime-surface { + background: linear-gradient(180deg, #121215 0%, #101012 100%); + border: 1px solid #1f1f24; + padding: var(--space-lg); +} + +.anime-section { + margin-bottom: 0; } .anime-poster { @@ -808,13 +865,13 @@ a.htmx-request { } .anime-actions { - margin-top: var(--space-lg); + margin-top: var(--space-md); } .anime-synopsis, .anime-relations, .anime-recommendations { - margin-bottom: var(--space-xl); + margin-bottom: 0; } .anime-synopsis h3, @@ -830,8 +887,8 @@ a.htmx-request { .anime-synopsis p { font-size: var(--text-md); - line-height: 1.8; - max-width: 65ch; + line-height: 1.65; + max-width: 75ch; color: var(--text); margin: 0; } @@ -843,7 +900,7 @@ a.htmx-request { /* Relations Grid */ .relations-grid { display: grid; - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: var(--space-lg); } @@ -895,22 +952,18 @@ a.htmx-request { /* Anime Sidebar */ .anime-sidebar { - flex-shrink: 0; width: var(--sidebar-width); - columns: 2; - column-gap: var(--space-md); - padding: var(--space-lg); - background: var(--surface); - border: none; - height: fit-content; + position: sticky; + top: 78px; + display: grid; + gap: var(--space-lg); } .sidebar-row { display: flex; flex-direction: column; gap: var(--space-xs); - margin-bottom: var(--space-md); - break-inside: avoid; + margin-bottom: 0; } .sidebar-label { @@ -920,6 +973,30 @@ a.htmx-request { text-transform: uppercase; } +.anime-side-section { + display: grid; + gap: var(--space-md); +} + +.anime-side-section h3 { + margin: 0; + font-size: var(--text-sm); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.side-details-more summary { + cursor: pointer; + color: var(--text); + font-size: var(--text-sm); + user-select: none; +} + +.side-details-more[open] { + gap: var(--space-sm); +} + .sidebar-value { font-size: var(--text-base); color: var(--text-muted); @@ -1040,11 +1117,12 @@ a.htmx-request { /* Sort Filter */ .sort-filter { display: flex; - gap: var(--space-lg); + gap: var(--space-md); margin-bottom: var(--space-lg); - padding: var(--space-lg); + padding: var(--space-md); background: var(--surface); border: none; + flex-wrap: wrap; } .sort-filter-group { @@ -1074,16 +1152,46 @@ a.htmx-request { background: var(--surface-hover); } +.density-group { + margin-left: auto; +} + +.density-label { + font-size: var(--text-base); + color: var(--text-muted); + font-weight: 500; +} + +.density-toggle { + background: var(--surface-hover); + border: 1px solid var(--border); + color: var(--text); + padding: var(--space-xs) var(--space-sm); + font-size: var(--text-sm); + cursor: pointer; +} + +body.density-compact .catalog-grid, +body.density-compact .schedule-grid, +body.density-compact .notifications-list { + gap: var(--space-md); +} + +body.density-compact .notification-content, +body.density-compact .schedule-card-info { + padding: var(--space-sm); +} + /* Responsive */ @media (max-width: 900px) { .anime-page { - flex-direction: column; + grid-template-columns: minmax(0, 1fr); } .anime-sidebar { width: 100%; order: 2; - grid-template-columns: repeat(3, 1fr); + position: static; } .anime-sidebar .sidebar-row { @@ -1150,9 +1258,27 @@ a.htmx-request { } .relations-grid { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: var(--space-md); } + + .watchlist-header { + flex-direction: column; + } + + .watchlist-controls { + width: 100%; + justify-content: flex-start; + } + + .sort-filter { + flex-direction: column; + align-items: stretch; + } + + .density-group { + margin-left: 0; + } } /* Tabs */ @@ -1161,18 +1287,20 @@ a.htmx-request { gap: var(--space-lg); margin-bottom: var(--space-xl); border-bottom: 1px solid var(--border); - padding-bottom: var(--space-md); + padding-bottom: var(--space-sm); + overflow-x: auto; } .tab { background: none; border: none; color: var(--text-muted); - font-size: var(--text-xl); + font-size: var(--text-md); cursor: pointer; - padding: var(--space-md) var(--space-lg); + padding: var(--space-sm) var(--space-md); transition: color 0.2s; text-transform: lowercase; + white-space: nowrap; } .tab:hover { @@ -1181,14 +1309,39 @@ a.htmx-request { .tab.active { color: var(--link); - font-weight: bold; + font-weight: 600; +} + +.discover-container { + display: grid; + gap: var(--space-lg); +} + +.discover-header { + display: grid; + gap: var(--space-xs); +} + +.discover-header h1 { + margin: 0; + font-size: var(--text-2xl); +} + +.discover-subtitle { + margin: 0; + font-size: var(--text-sm); + color: var(--text-muted); +} + +.discover-tabs { + margin-bottom: 0; } /* Schedule Page */ .schedule-page { max-width: 1400px; margin: 0 auto; - padding: var(--space-2xl) var(--space-xl); + padding: var(--space-lg) 0; } .schedule-page h1 { @@ -1209,6 +1362,7 @@ a.htmx-request { margin-bottom: var(--space-xl); border-bottom: 1px solid var(--border); padding-bottom: var(--space-sm); + overflow-x: auto; } .schedule-tab { @@ -1240,7 +1394,7 @@ a.htmx-request { .schedule-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 12vw + 80px, 200px), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: var(--space-2xl) var(--space-lg); } @@ -1339,7 +1493,7 @@ a.htmx-request { .notifications-page { max-width: 1400px; margin: 0 auto; - padding: var(--space-2xl) var(--space-xl); + padding: var(--space-lg) 0; } .notifications-page h1 { @@ -1367,10 +1521,24 @@ a.htmx-request { .notifications-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 12vw + 80px, 200px), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: var(--space-lg); } +.notifications-section-title { + margin-top: var(--space-2xl); +} + +.notifications-group-title { + font-size: var(--text-md); + margin-bottom: var(--space-md); + color: var(--text-muted); +} + +.notifications-list-spaced { + margin-bottom: var(--space-xl); +} + .notification-card { background: var(--surface); overflow: hidden; @@ -1400,21 +1568,11 @@ a.htmx-request { opacity: 0.5; } -.notification-card:hover { - transform: translateY(-2px); - text-decoration: none; - color: var(--text); -} - .notification-image img { width: 100%; height: 100%; object-fit: cover; } - width: 100%; - height: 100%; - object-fit: cover; -} .notification-content { padding: var(--space-md); @@ -1460,6 +1618,10 @@ a.htmx-request { font-weight: 600; } +.notification-muted { + color: var(--text-muted) !important; +} + .notification-synopsis { font-size: var(--text-xs); line-height: 1.4; diff --git a/static/js/anime.js b/static/js/anime.js new file mode 100644 index 0000000..c10c5a1 --- /dev/null +++ b/static/js/anime.js @@ -0,0 +1,28 @@ +;(function () { + const toggleDropdown = () => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + dropdown.classList.toggle('open') + } + + window.toggleDropdown = toggleDropdown + + document.addEventListener('click', (event) => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + const target = event.target + if (!(target instanceof Node)) { + return + } + + if (!dropdown.contains(target)) { + dropdown.classList.remove('open') + } + }) +})() diff --git a/static/js/discover.js b/static/js/discover.js new file mode 100644 index 0000000..f445a94 --- /dev/null +++ b/static/js/discover.js @@ -0,0 +1,26 @@ +;(function () { + const setActiveTab = (clickedTab) => { + const group = clickedTab.closest('[data-tab-group="discover"]') + if (!group) { + return + } + + const triggers = group.querySelectorAll('[data-tab-trigger]') + triggers.forEach((tab) => tab.classList.remove('active')) + clickedTab.classList.add('active') + } + + document.addEventListener('click', (event) => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + const trigger = target.closest('[data-tab-trigger]') + if (!trigger) { + return + } + + setActiveTab(trigger) + }) +})() diff --git a/static/js/schedule.js b/static/js/schedule.js new file mode 100644 index 0000000..da2da00 --- /dev/null +++ b/static/js/schedule.js @@ -0,0 +1,29 @@ +;(function () { + const contentSelector = '#schedule-content' + + const loadDay = (tab) => { + const day = tab.getAttribute('data-day') + if (!day || typeof htmx === 'undefined') { + return + } + + const tabs = document.querySelectorAll('[data-schedule-tab]') + tabs.forEach((item) => item.classList.remove('active')) + tab.classList.add('active') + htmx.ajax('GET', `/api/schedule?day=${day}`, contentSelector) + } + + document.addEventListener('click', (event) => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + const tab = target.closest('[data-schedule-tab]') + if (!tab) { + return + } + + loadDay(tab) + }) +})()