Merge branch '36-implement-likes-and-dislikes' into 'main'
Implement likes and dislikes Closes #36 See merge request libretunes/libretunes!32
This commit is contained in:
commit
7cb556e5ef
@ -0,0 +1,2 @@
|
||||
DROP TABLE song_likes;
|
||||
DROP TABLE song_dislikes;
|
@ -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)
|
||||
);
|
1
src/api/mod.rs
Normal file
1
src/api/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod songs;
|
55
src/api/songs.rs
Normal file
55
src/api/songs.rs
Normal file
@ -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::<NoCustomError>::
|
||||
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::<NoCustomError>::
|
||||
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::<NoCustomError>::
|
||||
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::<NoCustomError>::
|
||||
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::<NoCustomError>::
|
||||
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::<NoCustomError>::
|
||||
ServerError(format!("Error getting song liked: {}", e)))?;
|
||||
let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting song disliked: {}", e)))?;
|
||||
|
||||
Ok((like, dislike))
|
||||
}
|
23
src/auth.rs
23
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<User, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
auth_session.user.ok_or(ServerFnError::<NoCustomError>::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")]
|
||||
|
@ -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;
|
||||
|
||||
|
129
src/models.rs
129
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<dyn Error>> {
|
||||
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<bool, Box<dyn Error>> {
|
||||
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<Vec<Song>, Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<bool, Box<dyn Error>> {
|
||||
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<Vec<Song>, Box<dyn Error>> {
|
||||
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))]
|
||||
|
118
src/playbar.rs
118
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<PlayStatus>) -> impl IntoView {
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="media-info">
|
||||
<img class="media-info-img" align="left" src={image}/>
|
||||
<div class="media-info-text">
|
||||
{name}
|
||||
<br/>
|
||||
{artist} - {album}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// The like and dislike buttons
|
||||
#[component]
|
||||
fn LikeDislike(status: RwSignal<PlayStatus>) -> 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! {
|
||||
<div class="like-dislike">
|
||||
<button on:click=toggle_dislike>
|
||||
<Icon class="controlbtn hmirror" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=dislike_icon />
|
||||
</button>
|
||||
<button on:click=toggle_like>
|
||||
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=like_icon />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@ -488,7 +601,10 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
||||
<div class="playbar">
|
||||
<ProgressBar percentage=percentage.into() status=status />
|
||||
<div class="playbar-left-group">
|
||||
<MediaInfo status=status />
|
||||
<LikeDislike status=status />
|
||||
</div>
|
||||
<PlayControls status=status />
|
||||
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
||||
<QueueToggle status=status />
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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<SongData> for Song {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
/// 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<SongData, Self::Error> {
|
||||
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<Song> for SongData {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
@ -72,7 +37,7 @@ impl TryInto<Song> 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<Song, Self::Error> {
|
||||
Ok(Song {
|
||||
id: Some(self.id),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user