diff --git a/src/api/profile.rs b/src/api/profile.rs
index 790af13..c8c8716 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::artistdata::ArtistData;
+
+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,241 @@ 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/placeholders/MusicPlaceholder.svg".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/placeholders/MusicPlaceholder.svg".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)?
+ };
+
+ 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/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 e799679..401979d 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(Clone, Serialize, Deserialize)]
pub struct ArtistData {
/// Artist id
pub id: i32,
diff --git a/src/components.rs b/src/components.rs
index 893727c..2624877 100644
--- a/src/components.rs
+++ b/src/components.rs
@@ -6,3 +6,5 @@ pub mod dashboard_tile;
pub mod dashboard_row;
pub mod upload;
pub mod song_list;
+pub mod loading;
+pub mod error;
diff --git a/src/components/error.rs b/src/components/error.rs
new file mode 100644
index 0000000..ae2f18a
--- /dev/null
+++ b/src/components/error.rs
@@ -0,0 +1,45 @@
+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!{
+
+ })}
}
}
diff --git a/src/pages.rs b/src/pages.rs
index 40f63fd..1e65f58 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 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! {
+
+ }
+}
+
+/// 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/src/songdata.rs b/src/songdata.rs
index 3842c36..bbbb64b 100644
--- a/src/songdata.rs
+++ b/src/songdata.rs
@@ -1,12 +1,13 @@
use crate::models::{Album, Artist, Song};
use crate::components::dashboard_tile::DashboardTile;
+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(Clone)]
+#[derive(Serialize, Deserialize, Clone)]
pub struct SongData {
/// Song id
pub id: i32,
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