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

View File

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

28
static/js/anime.js Normal file
View File

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

26
static/js/discover.js Normal file
View File

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

29
static/js/schedule.js Normal file
View File

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