Reworked Artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s

This commit is contained in:
2026-05-25 17:41:00 +03:00
parent cae77e9401
commit 8530016d35
4 changed files with 442 additions and 2 deletions
+261
View File
@@ -208,6 +208,84 @@ button.user-stat:hover {
.sidebar-nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
.sidebar-section {
padding: 8px;
border-top: 1px solid var(--border-color);
}
.sidebar-section-title {
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-subdued);
}
.following-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 220px;
overflow-y: auto;
}
.following-artist {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.following-artist:hover,
.following-artist.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.following-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-elevated);
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.following-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.following-avatar svg {
width: 16px;
height: 16px;
color: var(--text-subdued);
}
.following-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
}
.following-empty {
padding: 8px 12px;
color: var(--text-subdued);
font-size: 12px;
}
.playlist-list {
flex: 1;
overflow-y: auto;
@@ -590,6 +668,49 @@ button.user-stat:hover {
color: var(--text-primary);
}
.release-action-btn.followed {
background: var(--accent);
color: #000;
}
.artist-follow-card-btn {
position: absolute;
bottom: 8px;
right: 8px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s, background 0.15s, color 0.15s;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
color: var(--text-primary);
}
.card:hover .artist-follow-card-btn,
.search-artist-card:hover .artist-follow-card-btn,
.artist-follow-card-btn.followed {
opacity: 1;
transform: translateY(0);
}
.artist-follow-card-btn.followed {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.artist-follow-card-btn svg {
width: 17px;
height: 17px;
}
/* Queue Panel */
.queue-panel {
width: var(--queue-width);
@@ -1166,6 +1287,7 @@ button.user-stat:hover {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
@@ -1704,6 +1826,7 @@ button.user-stat:hover {
.card-subtitle { font-size: 11px; }
.card-play-btn,
.card-enqueue-btn,
.artist-follow-card-btn,
.track-actions,
.playlist-item-actions,
.queue-track-actions,
@@ -2146,6 +2269,33 @@ button.user-stat:hover {
Artists
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title">
Following
<span x-show="$store.follows.artists.length > 0"
x-text="'(' + $store.follows.artists.length + ')'"></span>
</div>
<template x-if="$store.follows.artists.length === 0">
<div class="following-empty">No followed artists</div>
</template>
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
<template x-for="artist in $store.follows.artists" :key="artist.id">
<div class="following-artist"
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
@click="$store.library.openArtist(artist.id)">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div class="following-name" x-text="artist.name"></div>
</div>
</template>
</div>
</div>
<div class="playlist-list">
<template x-for="pl in $store.playlists.list" :key="pl.id">
<div class="playlist-item-row">
@@ -2279,6 +2429,17 @@ button.user-stat:hover {
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? 'Unfollow artist' : 'Follow artist'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
</div>
<div class="search-artist-name" x-text="artist.name"></div>
</div>
@@ -2380,6 +2541,17 @@ button.user-stat:hover {
<template x-if="!artist.image_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
</template>
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? 'Unfollow artist' : 'Follow artist'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
</div>
<div class="card-title" x-text="artist.name"></div>
<div class="card-subtitle" x-text="artist.release_count + ' releases · ' + artist.track_count + ' tracks'"></div>
@@ -2419,6 +2591,20 @@ button.user-stat:hover {
<span></span>
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
</div>
<div class="release-actions">
<button class="release-action-btn secondary"
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
@click="$store.follows.toggle($store.library.currentArtist.id)"
:title="$store.follows.has($store.library.currentArtist.id) ? 'Unfollow artist' : 'Follow artist'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has($store.library.currentArtist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has($store.library.currentArtist.id)" d="M16 11l2 2 4-5"/>
</svg>
<span x-text="$store.follows.has($store.library.currentArtist.id) ? 'Following' : 'Follow'"></span>
</button>
</div>
</div>
</div>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
@@ -3819,6 +4005,81 @@ document.addEventListener('alpine:init', () => {
},
});
// -----------------------------------------------------------------------
// Artist follows store
// -----------------------------------------------------------------------
Alpine.store('follows', {
_set: new Set(),
artists: [],
init() {
this.reload();
},
has(artistId) {
return this._set.has(Number(artistId));
},
async reload() {
try {
const res = await fetch('/api/player/follows');
if (!res.ok) return;
const data = await res.json();
this._set = new Set((data.artist_ids || []).map(Number));
this.artists = data.artists || [];
} catch {}
},
_artistSnapshot(artistId) {
const id = Number(artistId);
const library = Alpine.store('library');
const fromLists = [
...(library.artists || []),
...((library.searchResults && library.searchResults.artists) || []),
].find(artist => Number(artist.id) === id);
if (fromLists) return fromLists;
if (library.currentArtist && Number(library.currentArtist.id) === id) {
return {
id,
name: library.currentArtist.name,
image_url: library.currentArtist.image_url,
release_count: (library.currentArtist.releases || []).length,
track_count: library.currentArtist.total_track_count || 0,
};
}
return null;
},
async toggle(artistId) {
const id = Number(artistId);
if (!id) return;
if (this._set.has(id)) {
this._set.delete(id);
this.artists = this.artists.filter(artist => Number(artist.id) !== id);
} else {
this._set.add(id);
const snapshot = this._artistSnapshot(id);
if (snapshot && !this.artists.some(artist => Number(artist.id) === id)) {
this.artists = [snapshot, ...this.artists];
}
}
this._set = new Set(this._set);
try {
const res = await fetch(`/api/player/follows/toggle/${id}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
if (data.followed) this._set.add(id);
else this._set.delete(id);
this._set = new Set(this._set);
}
} catch {}
await this.reload();
},
});
// -----------------------------------------------------------------------
// Torrent import store
// -----------------------------------------------------------------------