Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8530016d35 |
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,17 @@ struct LikedIds {
|
|||||||
track_ids: Vec<i64>,
|
track_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct FollowStatus {
|
||||||
|
followed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct FollowedArtists {
|
||||||
|
artist_ids: Vec<i64>,
|
||||||
|
artists: Vec<ArtistCard>,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query helpers
|
// Query helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2112,6 +2123,124 @@ async fn liked_ids_handler(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/player/follows — get followed artists for current user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn followed_artists_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ArtistRow>(
|
||||||
|
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
||||||
|
COALESCE(s.release_count, 0)::bigint AS release_count,
|
||||||
|
COALESCE(s.track_count, 0)::bigint AS track_count
|
||||||
|
FROM furumusic__user_followed_artist ufa
|
||||||
|
JOIN furumusic__artist a ON a.id = ufa.artist_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ra.artist_id,
|
||||||
|
COUNT(DISTINCT r.id) AS release_count,
|
||||||
|
COUNT(t.id) AS track_count
|
||||||
|
FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
||||||
|
LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
||||||
|
WHERE ra.position = 0
|
||||||
|
GROUP BY ra.artist_id
|
||||||
|
) s ON s.artist_id = a.id
|
||||||
|
WHERE ufa.user_id = $1 AND a.is_hidden = false
|
||||||
|
ORDER BY ufa.created_at DESC, a.name_sort"#,
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let artist_ids = rows.iter().map(|row| row.id).collect();
|
||||||
|
let artists = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ArtistCard {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
image_url: cover_url(r.image_file_id),
|
||||||
|
release_count: r.release_count,
|
||||||
|
track_count: r.track_count,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(FollowedArtists {
|
||||||
|
artist_ids,
|
||||||
|
artists,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/player/follows/toggle/{id} — follow/unfollow artist
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn toggle_follow_artist_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
path: Path<PathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
let artist_id = path.0.id;
|
||||||
|
|
||||||
|
let artist_exists: Option<(i64,)> =
|
||||||
|
sqlx::query_as("SELECT id FROM furumusic__artist WHERE id = $1 AND is_hidden = false")
|
||||||
|
.bind(artist_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if artist_exists.is_none() {
|
||||||
|
return Ok(json_error(StatusCode::NOT_FOUND, "artist not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM furumusic__user_followed_artist WHERE user_id = $1 AND artist_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(artist_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM furumusic__user_followed_artist WHERE user_id = $1 AND artist_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(artist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Json(FollowStatus { followed: false }).into_response()
|
||||||
|
} else {
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__user_followed_artist (user_id, artist_id, created_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, artist_id) DO NOTHING"#,
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(artist_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Json(FollowStatus { followed: true }).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/player/tracks-by-ids
|
// POST /api/player/tracks-by-ids
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2701,6 +2830,56 @@ impl App for PlayerApp {
|
|||||||
}),
|
}),
|
||||||
"player_like_release",
|
"player_like_release",
|
||||||
),
|
),
|
||||||
|
// -- Followed artists --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/follows",
|
||||||
|
get({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session, db: Database| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
followed_artists_handler(session, db, pg_pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_follows",
|
||||||
|
),
|
||||||
|
// -- Follow/unfollow artist --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/follows/toggle/{id}",
|
||||||
|
post({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session, db: Database, path: Path<PathId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
toggle_follow_artist_handler(session, db, pg_pool, path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_follow_toggle",
|
||||||
|
),
|
||||||
// -- Audio stream --
|
// -- Audio stream --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/stream/{track_id}",
|
"/stream/{track_id}",
|
||||||
|
|||||||
@@ -208,6 +208,84 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.sidebar-nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
|
.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 {
|
.playlist-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -590,6 +668,49 @@ button.user-stat:hover {
|
|||||||
color: var(--text-primary);
|
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 */
|
||||||
.queue-panel {
|
.queue-panel {
|
||||||
width: var(--queue-width);
|
width: var(--queue-width);
|
||||||
@@ -1166,6 +1287,7 @@ button.user-stat:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
|
.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-subtitle { font-size: 11px; }
|
||||||
.card-play-btn,
|
.card-play-btn,
|
||||||
.card-enqueue-btn,
|
.card-enqueue-btn,
|
||||||
|
.artist-follow-card-btn,
|
||||||
.track-actions,
|
.track-actions,
|
||||||
.playlist-item-actions,
|
.playlist-item-actions,
|
||||||
.queue-track-actions,
|
.queue-track-actions,
|
||||||
@@ -2146,6 +2269,33 @@ button.user-stat:hover {
|
|||||||
Artists
|
Artists
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="playlist-list">
|
||||||
<template x-for="pl in $store.playlists.list" :key="pl.id">
|
<template x-for="pl in $store.playlists.list" :key="pl.id">
|
||||||
<div class="playlist-item-row">
|
<div class="playlist-item-row">
|
||||||
@@ -2279,6 +2429,17 @@ button.user-stat:hover {
|
|||||||
<template x-if="!artist.image_url">
|
<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>
|
<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>
|
</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>
|
||||||
<div class="search-artist-name" x-text="artist.name"></div>
|
<div class="search-artist-name" x-text="artist.name"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2380,6 +2541,17 @@ button.user-stat:hover {
|
|||||||
<template x-if="!artist.image_url">
|
<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>
|
<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>
|
</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>
|
||||||
<div class="card-title" x-text="artist.name"></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>
|
<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>•</span>
|
||||||
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
|
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
<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
|
// Torrent import store
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user