ui: declutter anime pages and controls
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
28
static/js/anime.js
Normal 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
26
static/js/discover.js
Normal 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
29
static/js/schedule.js
Normal 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)
|
||||
})
|
||||
})()
|
||||
Reference in New Issue
Block a user