From 88e2a229a422128862b1ca48738377b2debdcb59 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 21 Oct 2024 22:54:26 -0400 Subject: [PATCH 01/13] Move home component width to .home-component selector --- style/dashboard.scss | 1 - style/home.scss | 1 + style/search.scss | 2 -- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/style/dashboard.scss b/style/dashboard.scss index e0df7d0..733e58a 100644 --- a/style/dashboard.scss +++ b/style/dashboard.scss @@ -1,7 +1,6 @@ @import "theme.scss"; .dashboard-container { - width: calc(100% - 22rem - 16rem); .dashboard-header { font-size: 1.2rem; font-weight: 300; diff --git a/style/home.scss b/style/home.scss index 371032d..4d74ee0 100644 --- a/style/home.scss +++ b/style/home.scss @@ -10,6 +10,7 @@ .home-component { background: #1c1c1c; height: 100vh; + width: calc(100% - 22rem - 16rem); margin: 2px; padding: 0.2rem 1.5rem 1.5rem 1rem; border-radius: 0.5rem; diff --git a/style/search.scss b/style/search.scss index 9e43f4b..8a6c80d 100644 --- a/style/search.scss +++ b/style/search.scss @@ -1,6 +1,4 @@ @import "theme.scss"; .search-container { - width: calc(100% - 22rem - 16rem); - } From f1e177c7b074ee6b5367aa52d7e6303e0c0b88aa Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 21 Oct 2024 22:57:27 -0400 Subject: [PATCH 02/13] Add page for displaying error --- src/pages.rs | 3 ++- src/pages/error.rs | 24 ++++++++++++++++++++++++ style/error.scss | 18 ++++++++++++++++++ style/main.scss | 1 + 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/pages/error.rs create mode 100644 style/error.scss diff --git a/src/pages.rs b/src/pages.rs index 40f63fd..35dad03 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,2 +1,3 @@ pub mod login; -pub mod signup; \ No newline at end of file +pub mod signup; +pub mod error; diff --git a/src/pages/error.rs b/src/pages/error.rs new file mode 100644 index 0000000..9b68b8f --- /dev/null +++ b/src/pages/error.rs @@ -0,0 +1,24 @@ +use leptos::*; +use leptos_icons::*; +use std::fmt::Display; + +#[component] +pub fn ServerError( + #[prop(optional, into, default="An Error Occurred".into())] + title: TextProp, + #[prop(optional, into)] + message: TextProp, + #[prop(optional, into)] + error: Option>, +) -> impl IntoView { + view! { +
+
+ +

{title}

+
+

{message}

+

{error.map(|error| format!("{}", error))}

+
+ } +} diff --git a/style/error.scss b/style/error.scss new file mode 100644 index 0000000..dae3a36 --- /dev/null +++ b/style/error.scss @@ -0,0 +1,18 @@ +.error-container { + .error-header { + display: inline-grid; + + svg { + width: 30px; + height: 30px; + grid-row-start: 1; + align-self: center; + padding-right: 10px; + } + + h1 { + grid-row-start: 1; + align-self: center; + } + } +} diff --git a/style/main.scss b/style/main.scss index de15da6..3dfb627 100644 --- a/style/main.scss +++ b/style/main.scss @@ -9,6 +9,7 @@ @import 'search.scss'; @import 'personal.scss'; @import 'upload.scss'; +@import 'error.scss'; body { font-family: sans-serif; From 23bfb510c15369d809b1ac743935cb4d5eceabe9 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Fri, 1 Nov 2024 13:05:47 -0400 Subject: [PATCH 03/13] Add functions to get top/recent songs and artists from history --- src/api/profile.rs | 243 +++++++++++++++++++++++++++++++++++++++++++++ src/songdata.rs | 2 + 2 files changed, 245 insertions(+) diff --git a/src/api/profile.rs b/src/api/profile.rs index 790af13..0dcd6ec 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -3,10 +3,23 @@ use server_fn::codec::{MultipartData, MultipartFormData}; use cfg_if::cfg_if; +use crate::songdata::SongData; +use crate::models::Artist; + +use std::time::SystemTime; + cfg_if! { if #[cfg(feature = "ssr")] { use crate::auth::get_user; use server_fn::error::NoCustomError; + + use crate::database::get_db_conn; + use diesel::prelude::*; + use diesel::dsl::count; + use crate::models::*; + use crate::schema::*; + + use std::collections::HashMap; } } @@ -47,3 +60,233 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> { Ok(()) } + +/// Get a user's recent songs listened to +/// Optionally takes a limit parameter to limit the number of songs returned. +/// If not provided, all songs ever listend to are returned. +/// Returns a list of tuples with the date the song was listened to +/// and the song data, sorted by date (most recent first). +#[server(endpoint = "/profile/recent_songs")] +pub async fn recent_songs(for_user_id: i32, limit: Option) -> Result, ServerFnError> { + let mut db_con = get_db_conn(); + + // Get the ids of the most recent songs listened to + let history_items: Vec = + if let Some(limit) = limit { + song_history::table + .filter(song_history::user_id.eq(for_user_id)) + .order(song_history::date.desc()) + .limit(limit) + .select(song_history::id) + .load(&mut db_con)? + } else { + song_history::table + .filter(song_history::user_id.eq(for_user_id)) + .order(song_history::date.desc()) + .select(song_history::id) + .load(&mut db_con)? + }; + + // Take the history ids and get the song data for them + let history: Vec<(HistoryEntry, Song, Option, Option, Option<(i32, i32)>, Option<(i32, i32)>)> + = song_history::table + .filter(song_history::id.eq_any(history_items)) + .inner_join(songs::table) + .left_join(albums::table.on(songs::album_id.eq(albums::id.nullable()))) + .left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id))) + .left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id)))) + .left_join(song_dislikes::table.on( + songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id)))) + .select(( + song_history::all_columns, + songs::all_columns, + albums::all_columns.nullable(), + artists::all_columns.nullable(), + song_likes::all_columns.nullable(), + song_dislikes::all_columns.nullable(), + )) + .load(&mut db_con)?; + + // Process the history data into a map of song ids to song data + let mut history_songs: HashMap = HashMap::with_capacity(history.len()); + + for (history, song, album, artist, like, dislike) in history { + let song_id = history.song_id; + + if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) { + // If the song is already in the map, update the artists + if let Some(artist) = artist { + stored_songdata.artists.push(artist); + } + } else { + let like_dislike = match (like, dislike) { + (Some(_), Some(_)) => Some((true, true)), + (Some(_), None) => Some((true, false)), + (None, Some(_)) => Some((false, true)), + _ => None, + }; + + let image_path = song.image_path.unwrap_or( + album.as_ref().map(|album| album.image_path.clone()).flatten() + .unwrap_or("/assets/images/placeholder.jpg".to_string())); + + let songdata = SongData { + id: song_id, + title: song.title, + artists: artist.map(|artist| vec![artist]).unwrap_or_default(), + album: album, + track: song.track, + duration: song.duration, + release_date: song.release_date, + song_path: song.storage_path, + image_path: image_path, + like_dislike: like_dislike, + }; + + history_songs.insert(song_id, (history.date, songdata)); + } + } + + // Sort the songs by date + let mut history_songs: Vec<(SystemTime, SongData)> = history_songs.into_values().collect(); + history_songs.sort_by(|a, b| b.0.cmp(&a.0)); + Ok(history_songs) +} + +/// Get a user's top songs by play count from a date range +/// Optionally takes a limit parameter to limit the number of songs returned. +/// If not provided, all songs listened to in the date range are returned. +/// Returns a list of tuples with the play count and the song data, sorted by play count (most played first). +#[server(endpoint = "/profile/top_songs")] +pub async fn top_songs(for_user_id: i32, start_date: SystemTime, end_date: SystemTime, limit: Option) + -> Result, ServerFnError> +{ + let mut db_con = get_db_conn(); + + // Get the play count and ids of the songs listened to in the date range + let history_counts: Vec<(i32, i64)> = + if let Some(limit) = limit { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .group_by(song_history::song_id) + .select((song_history::song_id, count(song_history::song_id))) + .order(count(song_history::song_id).desc()) + .limit(limit) + .load(&mut db_con)? + } else { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .group_by(song_history::song_id) + .select((song_history::song_id, count(song_history::song_id))) + .load(&mut db_con)? + }; + + let history_counts: HashMap = history_counts.into_iter().collect(); + let history_song_ids = history_counts.iter().map(|(song_id, _)| *song_id).collect::>(); + + // Get the song data for the songs listened to in the date range + let history_songs: Vec<(Song, Option, Option, Option<(i32, i32)>, Option<(i32, i32)>)> + = songs::table + .filter(songs::id.eq_any(history_song_ids)) + .left_join(albums::table.on(songs::album_id.eq(albums::id.nullable()))) + .left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id))) + .left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id)))) + .left_join(song_dislikes::table.on( + songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id)))) + .select(( + songs::all_columns, + albums::all_columns.nullable(), + artists::all_columns.nullable(), + song_likes::all_columns.nullable(), + song_dislikes::all_columns.nullable(), + )) + .load(&mut db_con)?; + + // Process the history data into a map of song ids to song data + let mut history_songs_map: HashMap = HashMap::with_capacity(history_counts.len()); + + for (song, album, artist, like, dislike) in history_songs { + let song_id = song.id + .ok_or(ServerFnError::ServerError::("Song id not found in database".to_string()))?; + + if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song_id) { + // If the song is already in the map, update the artists + if let Some(artist) = artist { + stored_songdata.artists.push(artist); + } + } else { + let like_dislike = match (like, dislike) { + (Some(_), Some(_)) => Some((true, true)), + (Some(_), None) => Some((true, false)), + (None, Some(_)) => Some((false, true)), + _ => None, + }; + + let image_path = song.image_path.unwrap_or( + album.as_ref().map(|album| album.image_path.clone()).flatten() + .unwrap_or("/assets/images/placeholder.jpg".to_string())); + + let songdata = SongData { + id: song_id, + title: song.title, + artists: artist.map(|artist| vec![artist]).unwrap_or_default(), + album: album, + track: song.track, + duration: song.duration, + release_date: song.release_date, + song_path: song.storage_path, + image_path: image_path, + like_dislike: like_dislike, + }; + + let plays = history_counts.get(&song_id) + .ok_or(ServerFnError::ServerError::("Song id not found in history counts".to_string()))?; + + history_songs_map.insert(song_id, (*plays, songdata)); + } + } + + // Sort the songs by play count + let mut history_songs: Vec<(i64, SongData)> = history_songs_map.into_values().collect(); + history_songs.sort_by(|a, b| b.0.cmp(&a.0)); + Ok(history_songs) +} + +/// Get a user's top artists by play count from a date range +/// Optionally takes a limit parameter to limit the number of artists returned. +/// If not provided, all artists listened to in the date range are returned. +/// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first). +#[server(endpoint = "/profile/top_artists")] +pub async fn top_artists(for_user_id: i32, start_date: SystemTime, end_date: SystemTime, limit: Option) + -> Result, ServerFnError> +{ + let mut db_con = get_db_conn(); + + let artist_counts: Vec<(i64, Artist)> = + if let Some(limit) = limit { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id))) + .inner_join(artists::table.on(song_artists::artist_id.eq(artists::id))) + .group_by(artists::id) + .select((count(artists::id), artists::all_columns)) + .order(count(artists::id).desc()) + .limit(limit) + .load(&mut db_con)? + } else { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id))) + .inner_join(artists::table.on(song_artists::artist_id.eq(artists::id))) + .group_by(artists::id) + .select((count(artists::id), artists::all_columns)) + .order(count(artists::id).desc()) + .load(&mut db_con)? + }; + + Ok(artist_counts) +} diff --git a/src/songdata.rs b/src/songdata.rs index 61e263f..3a09af3 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -1,10 +1,12 @@ use crate::models::{Album, Artist, Song}; +use serde::{Serialize, Deserialize}; use time::Date; /// Holds information about a song /// /// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids. +#[derive(Serialize, Deserialize)] pub struct SongData { /// Song id pub id: i32, From 0453aef37d2ee165fedb6b109783fcffb65b5855 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Fri, 1 Nov 2024 14:26:38 -0400 Subject: [PATCH 04/13] Fix incorrect placeholder image path --- src/api/profile.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/profile.rs b/src/api/profile.rs index 0dcd6ec..3525def 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -128,7 +128,7 @@ pub async fn recent_songs(for_user_id: i32, limit: Option) -> Result Date: Fri, 1 Nov 2024 14:36:38 -0400 Subject: [PATCH 05/13] Add component to display SongList with additional data --- src/components/song_list.rs | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/song_list.rs b/src/components/song_list.rs index 2ace92a..e0c46d7 100644 --- a/src/components/song_list.rs +++ b/src/components/song_list.rs @@ -18,8 +18,10 @@ pub fn SongList(songs: MaybeSignal>) -> impl IntoView { let playing = first_song.into(); first_song = false; + let extra = Option::<()>::None; + view! { - + } }).collect::>() }) @@ -29,7 +31,33 @@ pub fn SongList(songs: MaybeSignal>) -> impl IntoView { } #[component] -pub fn SongListItem(song: SongData, song_playing: MaybeSignal) -> impl IntoView { +pub fn SongListExtra(songs: MaybeSignal>) -> impl IntoView where + T: Clone + IntoView + 'static +{ + view! { + + { + songs.with(|songs| { + let mut first_song = true; + + songs.iter().map(|(song, extra)| { + let playing = first_song.into(); + first_song = false; + + view! { + + } + }).collect::>() + }) + } +
+ } +} + +#[component] +pub fn SongListItem(song: SongData, song_playing: MaybeSignal, extra: Option) -> impl IntoView where + T: IntoView + 'static +{ let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false)); let disliked = create_rw_signal(song.like_dislike.map(|(_, disliked)| disliked).unwrap_or(false)); @@ -44,6 +72,10 @@ pub fn SongListItem(song: SongData, song_playing: MaybeSignal) -> impl Int {format!("{}:{:02}", song.duration / 60, song.duration % 60)} + {extra.map(|extra| view! { + + {extra} + })} } } From 414489d1bedc890a9bc7fdda818326e061f6e964 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 00:29:53 -0500 Subject: [PATCH 06/13] Return ArtistData from top_artists --- src/api/profile.rs | 13 +++++++++++-- src/artistdata.rs | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/api/profile.rs b/src/api/profile.rs index 3525def..9d4348e 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -4,6 +4,7 @@ use server_fn::codec::{MultipartData, MultipartFormData}; use cfg_if::cfg_if; use crate::songdata::SongData; +use crate::artistdata::ArtistData; use crate::models::Artist; use std::time::SystemTime; @@ -260,7 +261,7 @@ pub async fn top_songs(for_user_id: i32, start_date: SystemTime, end_date: Syste /// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first). #[server(endpoint = "/profile/top_artists")] pub async fn top_artists(for_user_id: i32, start_date: SystemTime, end_date: SystemTime, limit: Option) - -> Result, ServerFnError> + -> Result, ServerFnError> { let mut db_con = get_db_conn(); @@ -288,5 +289,13 @@ pub async fn top_artists(for_user_id: i32, start_date: SystemTime, end_date: Sys .load(&mut db_con)? }; - Ok(artist_counts) + let artist_data: Vec<(i64, ArtistData)> = artist_counts.into_iter().map(|(plays, artist)| { + (plays, ArtistData { + id: artist.id.unwrap(), + name: artist.name, + image_path: format!("/assets/images/artists/{}.webp", artist.id.unwrap()), + }) + }).collect(); + + Ok(artist_data) } diff --git a/src/artistdata.rs b/src/artistdata.rs index e799679..9a2d5f2 100644 --- a/src/artistdata.rs +++ b/src/artistdata.rs @@ -1,8 +1,10 @@ use crate::components::dashboard_tile::DashboardTile; +use serde::{Serialize, Deserialize}; /// Holds information about an artist /// /// Intended to be used in the front-end +#[derive(Serialize, Deserialize)] pub struct ArtistData { /// Artist id pub id: i32, From ddcb4a5be7c69ff4a952d170d4a25e8254be4648 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 15:13:07 -0500 Subject: [PATCH 07/13] Add loading indicator --- src/components.rs | 1 + src/components/loading.rs | 19 +++++++++++++ style/loading.scss | 59 +++++++++++++++++++++++++++++++++++++++ style/main.scss | 1 + 4 files changed, 80 insertions(+) create mode 100644 src/components/loading.rs create mode 100644 style/loading.scss diff --git a/src/components.rs b/src/components.rs index 893727c..023602d 100644 --- a/src/components.rs +++ b/src/components.rs @@ -6,3 +6,4 @@ pub mod dashboard_tile; pub mod dashboard_row; pub mod upload; pub mod song_list; +pub mod loading; diff --git a/src/components/loading.rs b/src/components/loading.rs new file mode 100644 index 0000000..b3de9cd --- /dev/null +++ b/src/components/loading.rs @@ -0,0 +1,19 @@ +use leptos::*; + +/// A loading indicator +#[component] +pub fn Loading() -> impl IntoView { + view! { +
+ } +} + +/// A full page, centered loading indicator +#[component] +pub fn LoadingPage() -> impl IntoView { + view!{ +
+ +
+ } +} diff --git a/style/loading.scss b/style/loading.scss new file mode 100644 index 0000000..89ae90e --- /dev/null +++ b/style/loading.scss @@ -0,0 +1,59 @@ +@import "theme.scss"; + +.loading-page { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} + +.loading { + position: relative; + width: 10px; + height: 10px; + border-radius: 5px; + margin: 10px; + background-color: $accent-color; + color: $accent-color; + animation: dot-flashing 1s infinite linear alternate; + animation-delay: 0.5s; +} + +.loading::before, .loading::after { + content: ""; + display: inline-block; + position: absolute; + top: 0; +} + +.loading::before { + left: -15px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: $accent-color; + color: $accent-color; + animation: dot-flashing 1s infinite alternate; + animation-delay: 0s; +} + +.loading::after { + left: 15px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: $accent-color; + color: $accent-color; + animation: dot-flashing 1s infinite alternate; + animation-delay: 1s; +} + +@keyframes dot-flashing { + 0% { + background-color: $accent-color; + } + + 50%, 100% { + background-color: $controls-hover-color; + } +} diff --git a/style/main.scss b/style/main.scss index 99faa8b..c581d58 100644 --- a/style/main.scss +++ b/style/main.scss @@ -13,6 +13,7 @@ @import 'upload.scss'; @import 'error.scss'; @import 'song_list.scss'; +@import 'loading.scss'; body { font-family: sans-serif; From 89433df8b6cd811dc13c4d16ebbec7c2386c727c Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 15:14:05 -0500 Subject: [PATCH 08/13] Add function to get user by id --- src/users.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/users.rs b/src/users.rs index 2d61c69..ff5606f 100644 --- a/src/users.rs +++ b/src/users.rs @@ -128,3 +128,15 @@ pub async fn get_user(username_or_email: String) -> Result, ServerF Ok(user) } + +#[server(endpoint = "get_user_by_id")] +pub async fn get_user_by_id(user_id: i32) -> Result, ServerFnError> { + let mut user = find_user_by_id(user_id).await?; + + // Remove the password hash before returning the user + if let Some(user) = user.as_mut() { + user.password = None; + } + + Ok(user) +} From 5011cda8fa9845aa0b4e2bf0710b278b1825eebb Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 17:01:42 -0500 Subject: [PATCH 09/13] Allow home-component to scroll --- style/home.scss | 4 ++-- style/personal.scss | 1 - style/playbar.scss | 2 +- style/theme.scss | 1 + 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/style/home.scss b/style/home.scss index 4d74ee0..1b4e896 100644 --- a/style/home.scss +++ b/style/home.scss @@ -9,9 +9,9 @@ } .home-component { background: #1c1c1c; - height: 100vh; width: calc(100% - 22rem - 16rem); margin: 2px; - padding: 0.2rem 1.5rem 1.5rem 1rem; + padding: 0.2rem 1.5rem $playbar-size 1rem; border-radius: 0.5rem; + overflow: scroll; } diff --git a/style/personal.scss b/style/personal.scss index e03e05e..3a83435 100644 --- a/style/personal.scss +++ b/style/personal.scss @@ -3,7 +3,6 @@ .personal-container { width: 16rem; background: #1c1c1c; - height: 100vh; margin: 2px; border-radius: 0.5rem; diff --git a/style/playbar.scss b/style/playbar.scss index 522ea11..698a2df 100644 --- a/style/playbar.scss +++ b/style/playbar.scss @@ -2,7 +2,7 @@ .playbar { width: 100%; - height: 75px; + height: $playbar-size; background-color: $play-bar-background-color; opacity: 0.9; position: fixed; diff --git a/style/theme.scss b/style/theme.scss index fe96046..b0cea43 100644 --- a/style/theme.scss +++ b/style/theme.scss @@ -16,3 +16,4 @@ $auth-inputs: #796dd4; $auth-containers: white; $dashboard-tile-size: 200px; +$playbar-size: 75px; From 39dd8099cd3f46f89f3875b9fea92e6645c5b2e3 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 17:02:59 -0500 Subject: [PATCH 10/13] Make ServerError a component instead of a page --- src/components.rs | 1 + src/{pages => components}/error.rs | 2 +- src/pages.rs | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{pages => components}/error.rs (91%) diff --git a/src/components.rs b/src/components.rs index 023602d..2624877 100644 --- a/src/components.rs +++ b/src/components.rs @@ -7,3 +7,4 @@ pub mod dashboard_row; pub mod upload; pub mod song_list; pub mod loading; +pub mod error; diff --git a/src/pages/error.rs b/src/components/error.rs similarity index 91% rename from src/pages/error.rs rename to src/components/error.rs index 9b68b8f..4691b55 100644 --- a/src/pages/error.rs +++ b/src/components/error.rs @@ -12,7 +12,7 @@ pub fn ServerError( error: Option>, ) -> impl IntoView { view! { -
+

{title}

diff --git a/src/pages.rs b/src/pages.rs index 35dad03..815d2a9 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,3 +1,2 @@ pub mod login; pub mod signup; -pub mod error; From 833393cb3a6426121b3a6e222ac1dc3323750d05 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 17:11:26 -0500 Subject: [PATCH 11/13] Add generic Error component --- src/components/error.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/components/error.rs b/src/components/error.rs index 4691b55..ae2f18a 100644 --- a/src/components/error.rs +++ b/src/components/error.rs @@ -10,6 +10,27 @@ pub fn ServerError( message: TextProp, #[prop(optional, into)] error: Option>, +) -> impl IntoView { + view!{ +
+
+ +

{title}

+
+

{message}

+

{error.map(|error| format!("{}", error))}

+
+ } +} + +#[component] +pub fn Error( + #[prop(optional, into, default="An Error Occurred".into())] + title: TextProp, + #[prop(optional, into)] + message: TextProp, + #[prop(optional, into)] + error: Option, ) -> impl IntoView { view! {
From ef5576ab3f402d710111a960855d92b695e0cefc Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 17:15:55 -0500 Subject: [PATCH 12/13] Create profile page --- src/app.rs | 5 +- src/artistdata.rs | 2 +- src/pages.rs | 1 + src/pages/profile.rs | 330 +++++++++++++++++++++++++++++++++++++++++++ style/main.scss | 1 + style/profile.scss | 36 +++++ 6 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/pages/profile.rs create mode 100644 style/profile.scss diff --git a/src/app.rs b/src/app.rs index 50cad40..15d877f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use leptos_meta::*; use leptos_router::*; use crate::pages::login::*; use crate::pages::signup::*; +use crate::pages::profile::*; use crate::error_template::{AppError, ErrorTemplate}; use crate::auth::get_logged_in_user; use crate::models::User; @@ -58,6 +59,8 @@ pub fn App() -> impl IntoView { + } /> + } /> } /> } /> @@ -70,7 +73,7 @@ pub fn App() -> impl IntoView { use crate::components::sidebar::*; use crate::components::dashboard::*; use crate::components::search::*; -use crate::components::personal::*; +use crate::components::personal::Personal; use crate::components::upload::*; /// Renders the home page of your application. diff --git a/src/artistdata.rs b/src/artistdata.rs index 9a2d5f2..401979d 100644 --- a/src/artistdata.rs +++ b/src/artistdata.rs @@ -4,7 +4,7 @@ use serde::{Serialize, Deserialize}; /// Holds information about an artist /// /// Intended to be used in the front-end -#[derive(Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct ArtistData { /// Artist id pub id: i32, diff --git a/src/pages.rs b/src/pages.rs index 815d2a9..1e65f58 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,2 +1,3 @@ pub mod login; pub mod signup; +pub mod profile; diff --git a/src/pages/profile.rs b/src/pages/profile.rs new file mode 100644 index 0000000..8bec37a --- /dev/null +++ b/src/pages/profile.rs @@ -0,0 +1,330 @@ +use leptos::*; +use leptos::logging::*; +use leptos_router::use_params_map; +use leptos_icons::*; +use server_fn::error::NoCustomError; + +use crate::components::dashboard_row::DashboardRow; +use crate::components::dashboard_tile::DashboardTile; +use crate::components::song_list::*; +use crate::components::loading::*; +use crate::components::error::*; + +use crate::api::profile::*; + +use crate::app::LoggedInUserResource; +use crate::models::User; +use crate::users::get_user_by_id; + +/// Duration in seconds backwards from now to aggregate history data for +const HISTORY_SECS: u64 = 60 * 60 * 24 * 30; +const HISTORY_MESSAGE: &str = "Last Month"; + +/// How many top songs to show +const TOP_SONGS_COUNT: i64 = 10; +/// How many recent songs to show +const RECENT_SONGS_COUNT: i64 = 5; +/// How many recent artists to show +const TOP_ARTISTS_COUNT: i64 = 10; + +/// Profile page +/// Shows the current user's profile if no id is specified, or a user's profile if an id is specified in the path +#[component] +pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView { + let params = use_params_map(); + + view! { +
+ {move || params.with(|params| { + match params.get("id").map(|id| id.parse::()) { + None => { + // No id specified, show the current user's profile + view! { }.into_view() + }, + Some(Ok(id)) => { + // Id specified, get the user and show their profile + view! { }.into_view() + }, + Some(Err(e)) => { + // Invalid id, return an error + view! { + + title="Invalid User ID" + error=e.to_string() + /> + }.into_view() + } + } + })} +
+ } +} + +/// Show the logged in user's profile +#[component] +fn OwnProfile(logged_in_user: LoggedInUserResource) -> impl IntoView { + view! { + } + > + {move || logged_in_user.get().map(|user| { + match user { + Some(user) => { + let user_id = user.id.unwrap(); + view! { + + + + + }.into_view() + }, + None => view! { + + title="Not Logged In" + message="You must be logged in to view your profile" + /> + }.into_view(), + } + })} + + } +} + +/// Show a user's profile by ID +#[component] +fn UserIdProfile(#[prop(into)] id: MaybeSignal) -> impl IntoView { + let user_info = create_resource(move || id.get(), move |id| { + get_user_by_id(id) + }); + + // Show the details if the user is found + let show_details = create_rw_signal(false); + + view!{ + } + > + {move || user_info.get().map(|user| { + match user { + Ok(Some(user)) => { + show_details.set(true); + + view! { }.into_view() + }, + Ok(None) => { + show_details.set(false); + + view! { + + title="User Not Found" + message=format!("User with ID {} not found", id.get()) + /> + }.into_view() + }, + Err(error) => { + show_details.set(false); + + view! { + + title="Error Getting User" + error + /> + }.into_view() + } + } + })} + + + } +} + +/// Show a profile for a User object +#[component] +fn UserProfile(user: User) -> impl IntoView { + let user_id = user.id.unwrap(); + let profile_image_path = format!("/assets/images/profile/{}.webp", user_id); + + view! { +
+ + + +

{user.username}

+
+
+

+ {user.email} + { + user.created_at.map(|created_at| { + use time::{OffsetDateTime, macros::format_description}; + let format = format_description!("[month repr:long] [year]"); + let date_time = Into::::into(created_at).format(format); + + match date_time { + Ok(date_time) => { + format!(" • Joined {}", date_time) + }, + Err(e) => { + error!("Error formatting date: {}", e); + String::new() + } + } + }) + } + { + if user.admin { + " • Admin" + } else { + "" + } + } +

+
+ } +} + +/// Show a list of top songs for a user +#[component] +fn TopSongs(#[prop(into)] user_id: MaybeSignal) -> impl IntoView { + let top_songs = create_resource(move || user_id.get(), |user_id| async move { + use std::time::{SystemTime, Duration}; + + let now = SystemTime::now(); + let start = now - Duration::from_secs(HISTORY_SECS); + let top_songs = top_songs(user_id, start, now, Some(TOP_SONGS_COUNT)).await; + + top_songs.map(|top_songs| { + top_songs.into_iter().map(|(plays, song)| { + let plays = if plays == 1 { + format!("{} Play", plays) + } else { + format!("{} Plays", plays) + }; + + (song, plays) + }).collect::>() + }) + }); + + view! { +

{format!("Top Songs {}", HISTORY_MESSAGE)}

+ } + > + {e.to_string()}

}) + .collect_view() + } + } + > + {move || + top_songs.get().map(|top_songs| { + top_songs.map(|top_songs| { + view! { + + } + }) + }) + } +
+
+ } +} + +/// Show a list of recently played songs for a user +#[component] +fn RecentSongs(#[prop(into)] user_id: MaybeSignal) -> impl IntoView { + let recent_songs = create_resource(move || user_id.get(), |user_id| async move { + let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await; + + recent_songs.map(|recent_songs| { + recent_songs.into_iter().map(|(_date, song)| { + song + }).collect::>() + }) + }); + + view! { +

"Recently Played"

+ } + > + {e.to_string()}

}) + .collect_view() + } + } + > + {move || + recent_songs.get().map(|recent_songs| { + recent_songs.map(|recent_songs| { + view! { + + } + }) + }) + } +
+
+ } +} + +/// Show a list of top artists for a user +#[component] +fn TopArtists(#[prop(into)] user_id: MaybeSignal) -> impl IntoView { + let top_artists = create_resource(move || user_id.get(), |user_id| async move { + use std::time::{SystemTime, Duration}; + + let now = SystemTime::now(); + let start = now - Duration::from_secs(HISTORY_SECS); + let top_artists = top_artists(user_id, start, now, Some(TOP_ARTISTS_COUNT)).await; + + top_artists.map(|top_artists| { + top_artists.into_iter().map(|(_plays, artist)| { + artist + }).collect::>() + }) + }); + + view! { + {format!("Top Artists {}", HISTORY_MESSAGE)} + + } + > + {format!("Top Artists {}", HISTORY_MESSAGE)} + {move || errors.get() + .into_iter() + .map(|(_, e)| view! {

{e.to_string()}

}) + .collect_view() + } + } + > + {move || + top_artists.get().map(|top_artists| { + top_artists.map(|top_artists| { + let tiles = top_artists.into_iter().map(|artist| { + Box::new(artist) as Box + }).collect::>(); + + DashboardRow::new(format!("Top Artists {}", HISTORY_MESSAGE), tiles) + }) + }) + } +
+
+ } +} diff --git a/style/main.scss b/style/main.scss index c581d58..a19abe7 100644 --- a/style/main.scss +++ b/style/main.scss @@ -13,6 +13,7 @@ @import 'upload.scss'; @import 'error.scss'; @import 'song_list.scss'; +@import 'profile.scss'; @import 'loading.scss'; body { diff --git a/style/profile.scss b/style/profile.scss new file mode 100644 index 0000000..e76c09a --- /dev/null +++ b/style/profile.scss @@ -0,0 +1,36 @@ +@import 'theme.scss'; + +.profile-container { + .profile-header { + display: flex; + + .profile-image { + width: 75px; + height: 75px; + border-radius: 50%; + padding: 10px; + padding-bottom: 5px; + margin-top: auto; + margin-bottom: auto; + + svg { + padding: 0; + margin: 0; + } + } + + h1 { + font-size: 40px; + align-self: center; + padding: 10px; + padding-bottom: 5px; + } + } + + .profile-details { + p { + font-size: 1rem; + margin: 0.5rem; + } + } +} From f23430af73ce9fb342e8891560cff7cf40ff3b9a Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Mon, 4 Nov 2024 17:16:09 -0500 Subject: [PATCH 13/13] Remove unnecessary Artist import --- src/api/profile.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/profile.rs b/src/api/profile.rs index 9d4348e..c8c8716 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -5,7 +5,6 @@ use cfg_if::cfg_if; use crate::songdata::SongData; use crate::artistdata::ArtistData; -use crate::models::Artist; use std::time::SystemTime;