diff --git a/migrations/2024-05-10-195644_create_likes_dislikes_table/down.sql b/migrations/2024-05-10-195644_create_likes_dislikes_table/down.sql new file mode 100644 index 0000000..c341129 --- /dev/null +++ b/migrations/2024-05-10-195644_create_likes_dislikes_table/down.sql @@ -0,0 +1,2 @@ +DROP TABLE song_likes; +DROP TABLE song_dislikes; diff --git a/migrations/2024-05-10-195644_create_likes_dislikes_table/up.sql b/migrations/2024-05-10-195644_create_likes_dislikes_table/up.sql new file mode 100644 index 0000000..aa8ed05 --- /dev/null +++ b/migrations/2024-05-10-195644_create_likes_dislikes_table/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE song_likes ( + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (song_id, user_id) +); + +CREATE TABLE song_dislikes ( + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (song_id, user_id) +); diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..fe30466 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1 @@ +pub mod songs; diff --git a/src/api/songs.rs b/src/api/songs.rs new file mode 100644 index 0000000..efb9209 --- /dev/null +++ b/src/api/songs.rs @@ -0,0 +1,55 @@ +use leptos::*; + +use cfg_if::cfg_if; + + +cfg_if! { + if #[cfg(feature = "ssr")] { + use leptos::server_fn::error::NoCustomError; + use crate::database::get_db_conn; + use crate::auth::get_user; + } +} + +/// Like or unlike a song +#[server(endpoint = "songs/set_like")] +pub async fn set_like_song(song_id: i32, like: bool) -> Result<(), ServerFnError> { + let user = get_user().await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting user: {}", e)))?; + + let db_con = &mut get_db_conn(); + + user.set_like_song(song_id, like, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error liking song: {}", e))) +} + +/// Dislike or remove dislike from a song +#[server(endpoint = "songs/set_dislike")] +pub async fn set_dislike_song(song_id: i32, dislike: bool) -> Result<(), ServerFnError> { + let user = get_user().await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting user: {}", e)))?; + + let db_con = &mut get_db_conn(); + + user.set_dislike_song(song_id, dislike, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error disliking song: {}", e))) +} + +/// Get the like and dislike status of a song +#[server(endpoint = "songs/get_like_dislike")] +pub async fn get_like_dislike_song(song_id: i32) -> Result<(bool, bool), ServerFnError> { + let user = get_user().await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting user: {}", e)))?; + + let db_con = &mut get_db_conn(); + + // TODO this could probably be done more efficiently with a tokio::try_join, but + // doing so is much more complicated than it would initially seem + + let like = user.get_like_song(song_id, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting song liked: {}", e)))?; + let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting song disliked: {}", e)))?; + + Ok((like, dislike)) +} diff --git a/src/auth.rs b/src/auth.rs index 8c33347..37f861f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -122,6 +122,29 @@ pub async fn require_auth() -> Result<(), ServerFnError> { }) } +/// Get the current logged-in user +/// Returns a Result with the user if they are logged in +/// Returns an error if the user is not logged in, or if there is an error getting the user +/// Intended to be used in a route to get the current user: +/// ```rust +/// use leptos::*; +/// use libretunes::auth::get_user; +/// #[server(endpoint = "user_route")] +/// pub async fn user_route() -> Result<(), ServerFnError> { +/// let user = get_user().await?; +/// println!("Logged in as: {}", user.username); +/// // Do something with the user +/// Ok(()) +/// } +/// ``` +#[cfg(feature = "ssr")] +pub async fn get_user() -> Result { + let auth_session = extract::>().await + .map_err(|e| ServerFnError::::ServerError(format!("Error getting auth session: {}", e)))?; + + auth_session.user.ok_or(ServerFnError::::ServerError("User not logged in".to_string())) +} + /// Check if a user is an admin /// Returns a Result with a boolean indicating if the user is logged in and an admin #[server(endpoint = "check_admin")] diff --git a/src/lib.rs b/src/lib.rs index e7949e3..7c48d1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod users; pub mod search; pub mod fileserv; pub mod error_template; +pub mod api; pub mod upload; pub mod util; diff --git a/src/models.rs b/src/models.rs index d3e4104..8bab205 100644 --- a/src/models.rs +++ b/src/models.rs @@ -45,6 +45,135 @@ pub struct User { pub admin: bool, } +impl User { + /// Like or unlike a song for this user + /// If likeing a song, remove dislike if it exists + #[cfg(feature = "ssr")] + pub async fn set_like_song(self: &Self, song_id: i32, like: bool, conn: &mut PgPooledConn) -> + Result<(), Box> { + use log::*; + debug!("Setting like for song {} to {}", song_id, like); + + use crate::schema::song_likes; + use crate::schema::song_dislikes; + + let my_id = self.id.ok_or("User id must be present (Some) to like/un-like a song")?; + + if like { + diesel::insert_into(song_likes::table) + .values((song_likes::song_id.eq(song_id), song_likes::user_id.eq(my_id))) + .execute(conn)?; + + // Remove dislike if it exists + diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id) + .and(song_dislikes::user_id.eq(my_id)))) + .execute(conn)?; + } else { + diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id)))) + .execute(conn)?; + } + + Ok(()) + } + + /// Get the like status of a song for this user + #[cfg(feature = "ssr")] + pub async fn get_like_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result> { + use crate::schema::song_likes; + + let my_id = self.id.ok_or("User id must be present (Some) to get like status of a song")?; + + let like = song_likes::table + .filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id))) + .first::<(i32, i32)>(conn) + .optional()? + .is_some(); + + Ok(like) + } + + /// Get songs liked by this user + #[cfg(feature = "ssr")] + pub async fn get_liked_songs(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::songs::dsl::*; + use crate::schema::song_likes::dsl::*; + + let my_id = self.id.ok_or("User id must be present (Some) to get liked songs")?; + + let my_songs = songs + .inner_join(song_likes) + .filter(user_id.eq(my_id)) + .select(songs::all_columns()) + .load(conn)?; + + Ok(my_songs) + } + + /// Dislike or remove dislike from a song for this user + /// If disliking a song, remove like if it exists + #[cfg(feature = "ssr")] + pub async fn set_dislike_song(self: &Self, song_id: i32, dislike: bool, conn: &mut PgPooledConn) -> + Result<(), Box> { + use log::*; + debug!("Setting dislike for song {} to {}", song_id, dislike); + + use crate::schema::song_likes; + use crate::schema::song_dislikes; + + let my_id = self.id.ok_or("User id must be present (Some) to dislike/un-dislike a song")?; + + if dislike { + diesel::insert_into(song_dislikes::table) + .values((song_dislikes::song_id.eq(song_id), song_dislikes::user_id.eq(my_id))) + .execute(conn)?; + + // Remove like if it exists + diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id) + .and(song_likes::user_id.eq(my_id)))) + .execute(conn)?; + } else { + diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id) + .and(song_dislikes::user_id.eq(my_id)))) + .execute(conn)?; + } + + Ok(()) + } + + /// Get the dislike status of a song for this user + #[cfg(feature = "ssr")] + pub async fn get_dislike_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result> { + use crate::schema::song_dislikes; + + let my_id = self.id.ok_or("User id must be present (Some) to get dislike status of a song")?; + + let dislike = song_dislikes::table + .filter(song_dislikes::song_id.eq(song_id).and(song_dislikes::user_id.eq(my_id))) + .first::<(i32, i32)>(conn) + .optional()? + .is_some(); + + Ok(dislike) + } + + /// Get songs disliked by this user + #[cfg(feature = "ssr")] + pub async fn get_disliked_songs(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::songs::dsl::*; + use crate::schema::song_likes::dsl::*; + + let my_id = self.id.ok_or("User id must be present (Some) to get disliked songs")?; + + let my_songs = songs + .inner_join(song_likes) + .filter(user_id.eq(my_id)) + .select(songs::all_columns()) + .load(conn)?; + + Ok(my_songs) + } +} + /// Model for an artist #[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))] #[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))] diff --git a/src/playbar.rs b/src/playbar.rs index 7ff2458..6ef6b7e 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -1,5 +1,7 @@ use crate::models::Artist; use crate::playstatus::PlayStatus; +use crate::songdata::SongData; +use crate::api::songs; use leptos::ev::MouseEvent; use leptos::html::{Audio, Div}; use leptos::leptos_dom::*; @@ -269,13 +271,124 @@ fn MediaInfo(status: RwSignal) -> impl IntoView { }); view! { -
{name}
{artist} - {album}
+ } +} + +/// The like and dislike buttons +#[component] +fn LikeDislike(status: RwSignal) -> impl IntoView { + let like_icon = Signal::derive(move || { + status.with(|status| { + match status.queue.front() { + Some(SongData { like_dislike: Some((true, _)), .. }) => icondata::TbThumbUpFilled, + _ => icondata::TbThumbUp, + } + }) + }); + + let dislike_icon = Signal::derive(move || { + status.with(|status| { + match status.queue.front() { + Some(SongData { like_dislike: Some((_, true)), .. }) => icondata::TbThumbDownFilled, + _ => icondata::TbThumbDown, + } + }) + }); + + let toggle_like = move |_| { + status.update(|status| { + match status.queue.front_mut() { + Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => { + *liked = !*liked; + + if *liked { + *disliked = false; + } + + let id = *id; + let liked = *liked; + spawn_local(async move { + if let Err(e) = songs::set_like_song(id, liked).await { + error!("Error liking song: {:?}", e); + } + }); + }, + Some(SongData { id, like_dislike, .. }) => { + // This arm should only be reached if like_dislike is None + // In this case, the buttons will show up not filled, indicating that the song is not + // liked or disliked. Therefore, clicking the like button should like the song. + + *like_dislike = Some((true, false)); + + let id = *id; + spawn_local(async move { + if let Err(e) = songs::set_like_song(id, true).await { + error!("Error liking song: {:?}", e); + } + }); + }, + _ => { + log!("Unable to like song: No song in queue"); + return; + } + } + }); + }; + + let toggle_dislike = move |_| { + status.update(|status| { + match status.queue.front_mut() { + Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => { + *disliked = !*disliked; + + if *disliked { + *liked = false; + } + + let id = *id; + let disliked = *disliked; + spawn_local(async move { + if let Err(e) = songs::set_dislike_song(id, disliked).await { + error!("Error disliking song: {:?}", e); + } + }); + }, + Some(SongData { id, like_dislike, .. }) => { + // This arm should only be reached if like_dislike is None + // In this case, the buttons will show up not filled, indicating that the song is not + // liked or disliked. Therefore, clicking the dislike button should dislike the song. + + *like_dislike = Some((false, true)); + + let id = *id; + spawn_local(async move { + if let Err(e) = songs::set_dislike_song(id, true).await { + error!("Error disliking song: {:?}", e); + } + }); + }, + _ => { + log!("Unable to dislike song: No song in queue"); + return; + } + } + }); + }; + + view! { + } } @@ -488,7 +601,10 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView { on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
+
+ +
diff --git a/src/schema.rs b/src/schema.rs index e4964b9..1fc0d64 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -30,6 +30,20 @@ diesel::table! { } } +diesel::table! { + song_dislikes (song_id, user_id) { + song_id -> Int4, + user_id -> Int4, + } +} + +diesel::table! { + song_likes (song_id, user_id) { + song_id -> Int4, + user_id -> Int4, + } +} + diesel::table! { songs (id) { id -> Int4, @@ -58,6 +72,10 @@ diesel::joinable!(album_artists -> albums (album_id)); diesel::joinable!(album_artists -> artists (artist_id)); diesel::joinable!(song_artists -> artists (artist_id)); diesel::joinable!(song_artists -> songs (song_id)); +diesel::joinable!(song_dislikes -> songs (song_id)); +diesel::joinable!(song_dislikes -> users (user_id)); +diesel::joinable!(song_likes -> songs (song_id)); +diesel::joinable!(song_likes -> users (user_id)); diesel::joinable!(songs -> albums (album_id)); diesel::allow_tables_to_appear_in_same_query!( @@ -65,6 +83,8 @@ diesel::allow_tables_to_appear_in_same_query!( albums, artists, song_artists, + song_dislikes, + song_likes, songs, users, ); diff --git a/src/songdata.rs b/src/songdata.rs index 23ce415..61e263f 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -26,45 +26,10 @@ pub struct SongData { /// Path to song image, relative to the root of the web server. /// For example, `"/assets/images/Song.jpg"` pub image_path: String, + /// Whether the song is liked by the user + pub like_dislike: Option<(bool, bool)>, } -#[cfg(feature = "ssr")] -impl TryInto for Song { - type Error = Box; - - /// Convert a Song object into a SongData object - /// - /// This conversion is expensive, as it requires database queries to get the artist and album objects. - /// The SongData/Song conversions are also not truly reversible, - /// due to the way the image_path, album, and artist data is handled. - fn try_into(self) -> Result { - use crate::database; - let mut db_con = database::get_db_conn(); - - let album = self.get_album(&mut db_con)?; - - // Use the song's image path if it exists, otherwise use the album's image path, or fallback to the placeholder - let image_path = self.image_path.clone().unwrap_or_else(|| { - album - .as_ref() - .and_then(|album| album.image_path.clone()) - .unwrap_or_else(|| "/assets/images/placeholder.jpg".to_string()) - }); - - Ok(SongData { - id: self.id.ok_or("Song id must be present (Some) to convert to SongData")?, - title: self.title.clone(), - artists: self.get_artists(&mut db_con)?, - album: album, - track: self.track, - duration: self.duration, - release_date: self.release_date, - // TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35 - song_path: self.storage_path, - image_path: image_path, - }) - } -} impl TryInto for SongData { type Error = Box; @@ -72,7 +37,7 @@ impl TryInto for SongData { /// Convert a SongData object into a Song object /// /// The SongData/Song conversions are also not truly reversible, - /// due to the way the image_path, album, and and artist data is handled. + /// due to the way the image_path data is handled. fn try_into(self) -> Result { Ok(Song { id: Some(self.id), diff --git a/style/playbar.scss b/style/playbar.scss index 6d91b94..522ea11 100644 --- a/style/playbar.scss +++ b/style/playbar.scss @@ -39,15 +39,12 @@ } } - .media-info { - font-size: 16; - margin-left: 10px; - + .playbar-left-group { + display: flex; position: absolute; top: 50%; transform: translateY(-50%); - display: grid; - grid-template-columns: 50px 1fr; + margin-left: 10px; .media-info-img { width: 50px; @@ -57,6 +54,10 @@ text-align: left; margin-left: 10px; } + + .like-dislike { + margin-left: 20px; + } } .playcontrols { @@ -64,23 +65,6 @@ flex-direction: row; justify-content: center; align-items: center; - - button { - .controlbtn { - color: $text-controls-color; - } - - .controlbtn:hover { - color: $controls-hover-color; - } - - .controlbtn:active { - color: $controls-click-color; - } - - background-color: transparent; - border: transparent; - } } .playduration { @@ -94,22 +78,30 @@ bottom: 13px; top: 13px; right: 90px; + } - button { - .controlbtn { - color: $text-controls-color; - } - - .controlbtn:hover { - color: $controls-hover-color; - } - - .controlbtn:active { - color: $controls-click-color; - } - - background-color: transparent; - border: transparent; + button { + .hmirror { + -moz-transform: scale(-1, 1); + -webkit-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); } + + .controlbtn { + color: $text-controls-color; + } + + .controlbtn:hover { + color: $controls-hover-color; + } + + .controlbtn:active { + color: $controls-click-color; + } + + background-color: transparent; + border: transparent; } }