From 8530016d35bb9f1d75e06b11927c1339e93da565 Mon Sep 17 00:00:00 2001 From: AB Date: Mon, 25 May 2026 17:41:00 +0300 Subject: [PATCH] Reworked Artist page --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/player/mod.rs | 179 +++++++++++++++++++++++++++++ templates/player.html | 261 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14f9ff0..5c79e86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index cbd02bd..244265b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.6" +version = "0.1.7" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/player/mod.rs b/src/player/mod.rs index dc66309..d7452dc 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -237,6 +237,17 @@ struct LikedIds { track_ids: Vec, } +#[derive(Debug, Serialize, JsonSchema)] +struct FollowStatus { + followed: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct FollowedArtists { + artist_ids: Vec, + artists: Vec, +} + // --------------------------------------------------------------------------- // Query helpers // --------------------------------------------------------------------------- @@ -2112,6 +2123,124 @@ async fn liked_ids_handler( .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 { + 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, +) -> cot::Result { + 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 // --------------------------------------------------------------------------- @@ -2701,6 +2830,56 @@ impl App for PlayerApp { }), "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| { + 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 -- Route::with_handler_and_name( "/stream/{track_id}", diff --git a/templates/player.html b/templates/player.html index db4bbfd..06faffc 100644 --- a/templates/player.html +++ b/templates/player.html @@ -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 +