diff --git a/Cargo.lock b/Cargo.lock index 23984ed..44b8a0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -151,7 +151,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "hyper", @@ -167,7 +167,7 @@ dependencies = [ "serde", "sync_wrapper 1.0.0", "tokio", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", ] @@ -181,7 +181,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "mime", @@ -1017,13 +1017,14 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "gloo-net" version = "0.5.0" -source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" dependencies = [ "futures-channel", "futures-core", "futures-sink", "gloo-utils", - "http", + "http 0.2.12", "js-sys", "pin-project", "serde", @@ -1049,7 +1050,8 @@ dependencies = [ [[package]] name = "gloo-utils" version = "0.2.0" -source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" dependencies = [ "js-sys", "serde", @@ -1117,6 +1119,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.1.0" @@ -1135,7 +1148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -1146,7 +1159,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" dependencies = [ "bytes", "futures-core", - "http", + "http 1.1.0", "http-body", "pin-project-lite", ] @@ -1178,7 +1191,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.1.0", "http-body", "httparse", "httpdate", @@ -1196,7 +1209,7 @@ checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" dependencies = [ "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "hyper", "pin-project-lite", @@ -1826,7 +1839,7 @@ dependencies = [ "diesel_migrations", "dotenv", "flexi_logger", - "http", + "http 1.1.0", "icondata", "image-convert", "lazy_static", @@ -1846,7 +1859,7 @@ dependencies = [ "thiserror", "time", "tokio", - "tower", + "tower 0.5.1", "tower-http", "tower-sessions-redis-store", "wasm-bindgen", @@ -2023,7 +2036,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.1.0", "httparse", "log", "memchr", @@ -2670,7 +2683,7 @@ dependencies = [ "dashmap", "futures", "gloo-net", - "http", + "http 1.1.0", "http-body-util", "hyper", "inventory", @@ -2683,7 +2696,7 @@ dependencies = [ "serde_qs", "server_fn_macro_default", "thiserror", - "tower", + "tower 0.4.13", "tower-layer", "url", "wasm-bindgen", @@ -3109,6 +3122,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-cookies" version = "0.10.0" @@ -3119,7 +3146,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http", + "http 1.1.0", "parking_lot", "pin-project-lite", "tower-layer", @@ -3128,14 +3155,14 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" dependencies = [ "bitflags 2.5.0", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "http-range-header", @@ -3153,15 +3180,15 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-sessions" @@ -3170,7 +3197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b27326208b21807803c5f5aa1020d30ca0432b78cfe251b51a67a05e0baea102" dependencies = [ "async-trait", - "http", + "http 1.1.0", "time", "tokio", "tower-cookies", @@ -3191,7 +3218,7 @@ dependencies = [ "axum-core", "base64", "futures", - "http", + "http 1.1.0", "parking_lot", "rand", "serde", diff --git a/Cargo.toml b/Cargo.toml index f76ef2f..2bdcda4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,8 +28,8 @@ diesel_migrations = { version = "2.1.0", optional = true } pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true } tokio = { version = "1", optional = true, features = ["rt-multi-thread"] } axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true } -tower = { version = "0.4.13", optional = true } -tower-http = { version = "0.5", optional = true, features = ["fs"] } +tower = { version = "0.5.1", optional = true, features = ["util"] } +tower-http = { version = "0.6.1", optional = true, features = ["fs"] } thiserror = "1.0.57" tower-sessions-redis-store = { version = "0.11", optional = true } async-trait = { version = "0.1.79", optional = true } @@ -43,9 +43,6 @@ web-sys = "0.3.69" leptos-use = "0.13.5" image-convert = { version = "0.18.0", optional = true, default-features = false } -[patch.crates-io] -gloo-net = { git = "https://github.com/rustwasm/gloo.git", rev = "a823fab7ecc4068e9a28bd669da5eaf3f0a56380" } - [features] hydrate = [ "leptos/hydrate", diff --git a/src/albumdata.rs b/src/albumdata.rs new file mode 100644 index 0000000..e42baea --- /dev/null +++ b/src/albumdata.rs @@ -0,0 +1,39 @@ +use crate::models::Artist; +use crate::components::dashboard_tile::DashboardTile; + +use time::Date; + +/// Holds information about an album +/// +/// Intended to be used in the front-end +pub struct AlbumData { + /// Album id + pub id: i32, + /// Album title + pub title: String, + /// Album artists + pub artists: Vec, + /// Album release date + pub release_date: Option, + /// Path to album image, relative to the root of the web server. + /// For example, `"/assets/images/Album.jpg"` + pub image_path: String, +} + +impl DashboardTile for AlbumData { + fn image_path(&self) -> String { + self.image_path.clone() + } + + fn title(&self) -> String { + self.title.clone() + } + + fn link(&self) -> String { + format!("/album/{}", self.id) + } + + fn description(&self) -> Option { + Some(format!("Album • {}", Artist::display_list(&self.artists))) + } +} diff --git a/src/app.rs b/src/app.rs index 67a6c7a..ed9fd1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crate::playbar::PlayBar; +use crate::playbar::CustomTitle; use crate::playstatus::PlayStatus; use crate::queue::Queue; use leptos::*; @@ -24,7 +25,7 @@ pub fn App() -> impl IntoView { // sets the document title - + <CustomTitle play_status=play_status/> // content for this welcome page <Router fallback=|| { diff --git a/src/artistdata.rs b/src/artistdata.rs new file mode 100644 index 0000000..e799679 --- /dev/null +++ b/src/artistdata.rs @@ -0,0 +1,32 @@ +use crate::components::dashboard_tile::DashboardTile; + +/// Holds information about an artist +/// +/// Intended to be used in the front-end +pub struct ArtistData { + /// Artist id + pub id: i32, + /// Artist name + pub name: String, + /// Path to artist image, relative to the root of the web server. + /// For example, `"/assets/images/Artist.jpg"` + pub image_path: String, +} + +impl DashboardTile for ArtistData { + fn image_path(&self) -> String { + self.image_path.clone() + } + + fn title(&self) -> String { + self.name.clone() + } + + fn link(&self) -> String { + format!("/artist/{}", self.id) + } + + fn description(&self) -> Option<String> { + Some("Artist".to_string()) + } +} diff --git a/src/components.rs b/src/components.rs index ede9b31..893727c 100644 --- a/src/components.rs +++ b/src/components.rs @@ -2,4 +2,7 @@ pub mod sidebar; pub mod dashboard; pub mod search; pub mod personal; +pub mod dashboard_tile; +pub mod dashboard_row; pub mod upload; +pub mod song_list; diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs new file mode 100644 index 0000000..7abd258 --- /dev/null +++ b/src/components/dashboard_row.rs @@ -0,0 +1,118 @@ +use leptos::html::Ul; +use leptos::leptos_dom::*; +use leptos::*; +use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn}; +use crate::components::dashboard_tile::DashboardTile; +use leptos_icons::*; + +/// A row of dashboard tiles, with a title +pub struct DashboardRow { + pub title: String, + pub tiles: Vec<Box<dyn DashboardTile>>, +} + +impl DashboardRow { + pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self { + Self { + title, + tiles, + } + } +} + +impl IntoView for DashboardRow { + fn into_view(self) -> View { + let list_ref = create_node_ref::<Ul>(); + + // Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile + // This is done by scrolling to the nearest multiple of the tile width, plus some for padding + + let scroll_left = move |_| { + if let Some(scroll_element) = list_ref.get_untracked() { + let client_width = scroll_element.client_width() as f64; + let current_pos = scroll_element.scroll_left() as f64; + let desired_pos = current_pos - client_width; + + if let Some(first_tile) = scroll_element.first_element_child() { + let tile_width = first_tile.client_width() as f64; + let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width)); + scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0); + } else { + warn!("Could not get first tile to scroll left"); + // Fall back to scrolling by the client width if we can't get the tile width + scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0); + } + } else { + warn!("Could not get scroll element to scroll left"); + } + }; + + let scroll_right = move |_| { + if let Some(scroll_element) = list_ref.get_untracked() { + let client_width = scroll_element.client_width() as f64; + let current_pos = scroll_element.scroll_left() as f64; + let desired_pos = current_pos + client_width; + + if let Some(first_tile) = scroll_element.first_element_child() { + let tile_width = first_tile.client_width() as f64; + let scroll_pos = desired_pos - (desired_pos % tile_width); + scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0); + } else { + warn!("Could not get first tile to scroll right"); + // Fall back to scrolling by the client width if we can't get the tile width + scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0); + } + } else { + warn!("Could not get scroll element to scroll right"); + } + }; + + let UseElementSizeReturn { width: scroll_element_width, .. } = use_element_size(list_ref); + let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref); + + let scroll_right_hidden = Signal::derive(move || { + if let Some(scroll_element) = list_ref.get() { + if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() { + "visibility: hidden" + } else { + "" + } + } else { + "" + } + }); + + let scroll_left_hidden = Signal::derive(move || { + if scroll_x.get() <= 0.0 { + "visibility: hidden" + } else { + "" + } + }); + + view! { + <div class="dashboard-tile-row"> + <div class="dashboard-tile-row-title-row"> + <h2>{self.title}</h2> + <div class="dashboard-tile-row-scroll-btn"> + <button on:click=scroll_left tabindex=-1 style=scroll_left_hidden> + <Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronLeft /> + </button> + <button on:click=scroll_right tabindex=-1 style=scroll_right_hidden> + <Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronRight /> + </button> + </div> + </div> + <ul _ref={list_ref}> + {self.tiles.into_iter().map(|tile_info| { + view! { + <li> + { tile_info.into_view() } + </li> + } + }).collect::<Vec<_>>()} + </ul> + </div> + }.into_view() + } +} diff --git a/src/components/dashboard_tile.rs b/src/components/dashboard_tile.rs new file mode 100644 index 0000000..354aad5 --- /dev/null +++ b/src/components/dashboard_tile.rs @@ -0,0 +1,27 @@ +use leptos::leptos_dom::*; +use leptos::*; + +pub trait DashboardTile { + fn image_path(&self) -> String; + fn title(&self) -> String; + fn link(&self) -> String; + fn description(&self) -> Option<String> { None } +} + +impl IntoView for &dyn DashboardTile { + fn into_view(self) -> View { + let link = self.link(); + + view! { + <div class="dashboard-tile"> + <a href={link}> + <img src={self.image_path()} alt="dashboard-tile" /> + <p class="dashboard-tile-title">{self.title()}</p> + <p class="dashboard-tile-description"> + {self.description().unwrap_or_default()} + </p> + </a> + </div> + }.into_view() + } +} diff --git a/src/components/song_list.rs b/src/components/song_list.rs new file mode 100644 index 0000000..2ace92a --- /dev/null +++ b/src/components/song_list.rs @@ -0,0 +1,157 @@ +use leptos::*; +use leptos_icons::*; + +use crate::songdata::SongData; +use crate::models::{Album, Artist}; + +const LIKE_DISLIKE_BTN_SIZE: &str = "2em"; + +#[component] +pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView { + view! { + <table class="song-list"> + { + songs.with(|songs| { + let mut first_song = true; + + songs.iter().map(|song| { + let playing = first_song.into(); + first_song = false; + + view! { + <SongListItem song={song.clone()} song_playing=playing /> + } + }).collect::<Vec<_>>() + }) + } + </table> + } +} + +#[component] +pub fn SongListItem(song: SongData, song_playing: MaybeSignal<bool>) -> impl IntoView { + 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)); + + view! { + <tr class="song-list-item"> + <td class="song-image"><SongImage image_path=song.image_path song_playing /></td> + <td class="song-title"><p>{song.title}</p></td> + <td class="song-list-spacer"></td> + <td class="song-artists"><SongArtists artists=song.artists /></td> + <td class="song-list-spacer"></td> + <td class="song-album"><SongAlbum album=song.album /></td> + <td class="song-list-spacer-big"></td> + <td class="song-like-dislike"><SongLikeDislike liked disliked/></td> + <td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td> + </tr> + } +} + +/// Display the song's image, with an overlay if the song is playing +/// When the song list item is hovered, the overlay will show the play button +#[component] +fn SongImage(image_path: String, song_playing: MaybeSignal<bool>) -> impl IntoView { + view! { + <img class="song-image" src={image_path}/> + {if song_playing.get() { + view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view() + } else { + view! { <Icon class="song-image-overlay hide-until-hover" icon=icondata::BsPlayFill /> }.into_view() + }} + } +} + +/// Displays a song's artists, with links to their artist pages +#[component] +fn SongArtists(artists: Vec<Artist>) -> impl IntoView { + let num_artists = artists.len() as isize; + + artists.iter().enumerate().map(|(i, artist)| { + let i = i as isize; + + view! { + { + if let Some(id) = artist.id { + view! { <a href={format!("/artist/{}", id)}>{artist.name.clone()}</a> }.into_view() + } else { + view! { <span>{artist.name.clone()}</span> }.into_view() + } + } + {if i < num_artists - 2 { ", " } else if i == num_artists - 2 { " & " } else { "" }} + } + }).collect::<Vec<_>>() +} + +/// Display a song's album, with a link to the album page +#[component] +fn SongAlbum(album: Option<Album>) -> impl IntoView { + album.as_ref().map(|album| { + view! { + <span> + { + if let Some(id) = album.id { + view! { <a href={format!("/album/{}", id)}>{album.title.clone()}</a> }.into_view() + } else { + view! { <span>{album.title.clone()}</span> }.into_view() + } + } + </span> + } + }) +} + +/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked +#[component] +fn SongLikeDislike(liked: RwSignal<bool>, disliked: RwSignal<bool>) -> impl IntoView { + let like_icon = Signal::derive(move || { + if liked.get() { + icondata::TbThumbUpFilled + } else { + icondata::TbThumbUp + } + }); + + let dislike_icon = Signal::derive(move || { + if disliked.get() { + icondata::TbThumbDownFilled + } else { + icondata::TbThumbDown + } + }); + + let like_class = MaybeProp::derive(move || { + if liked.get() { + Some(TextProp::from("controlbtn")) + } else { + Some(TextProp::from("controlbtn hide-until-hover")) + } + }); + + let dislike_class = MaybeProp::derive(move || { + if disliked.get() { + Some(TextProp::from("controlbtn hmirror")) + } else { + Some(TextProp::from("controlbtn hmirror hide-until-hover")) + } + }); + + let toggle_like = move |_| { + liked.set(!liked.get_untracked()); + disliked.set(disliked.get_untracked() && !liked.get_untracked()); + }; + + let toggle_dislike = move |_| { + disliked.set(!disliked.get_untracked()); + liked.set(liked.get_untracked() && !disliked.get_untracked()); + }; + + view! { + <button on:click=toggle_dislike> + <Icon class=dislike_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=dislike_icon /> + </button> + <button on:click=toggle_like> + <Icon class=like_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=like_icon /> + </button> + } +} diff --git a/src/lib.rs b/src/lib.rs index 7c48d1b..95ac8ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ pub mod app; pub mod auth; pub mod songdata; +pub mod albumdata; +pub mod artistdata; pub mod playstatus; pub mod playbar; pub mod database; diff --git a/src/playbar.rs b/src/playbar.rs index f0055e4..d113b7a 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -5,6 +5,7 @@ use crate::api::songs; use leptos::ev::MouseEvent; use leptos::html::{Audio, Div}; use leptos::leptos_dom::*; +use leptos_meta::Title; use leptos::*; use leptos_icons::*; use leptos_use::{utils::Pausable, use_interval_fn}; @@ -460,6 +461,21 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView { } } +/// Renders the title of the page based on the currently playing song +#[component] +pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView { + let title = create_memo(move |_| { + play_status.with(|play_status| { + play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| { + format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes") + }) + }) + }); + view! { + <Title text=title /> + } +} + /// The main play bar component, containing the progress bar, media info, play controls, and play duration #[component] pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { diff --git a/src/songdata.rs b/src/songdata.rs index 3a09af3..1266bf4 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -1,4 +1,5 @@ use crate::models::{Album, Artist, Song}; +use crate::components::dashboard_tile::DashboardTile; use serde::{Serialize, Deserialize}; use time::Date; @@ -6,7 +7,7 @@ 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)] +#[derive(Serialize, Deserialize, Clone)] pub struct SongData { /// Song id pub id: i32, @@ -62,3 +63,21 @@ impl TryInto<Song> for SongData { }) } } + +impl DashboardTile for SongData { + fn image_path(&self) -> String { + self.image_path.clone() + } + + fn title(&self) -> String { + self.title.clone() + } + + fn link(&self) -> String { + format!("/song/{}", self.id) + } + + fn description(&self) -> Option<String> { + Some(format!("Song • {}", Artist::display_list(&self.artists))) + } +} diff --git a/style/dashboard_row.scss b/style/dashboard_row.scss new file mode 100644 index 0000000..8bbb2c9 --- /dev/null +++ b/style/dashboard_row.scss @@ -0,0 +1,45 @@ +.dashboard-tile-row { + .dashboard-tile-row-title-row { + display: flex; + + .dashboard-tile-row-scroll-btn { + margin-left: auto; + margin-top: auto; + margin-bottom: auto; + + button { + background-color: transparent; + border: none; + + .dashboard-tile-row-scroll { + color: $text-controls-color; + width: 2.5rem; + height: 2.5rem; + } + + .dashboard-tile-row-scroll:hover { + color: $controls-hover-color; + } + + .dashboard-tile-row-scroll:active { + color: $controls-click-color; + } + } + } + } + + ul { + display: flex; + overflow-x: hidden; + scroll-behavior: smooth; + margin-left: 40px; + padding-inline-start: 0; + + li { + list-style-type: none; + } + + -webkit-mask-image: linear-gradient(90deg, #000000 95%, transparent); + mask-image: linear-gradient(90deg, #000000 95%, transparent); + } +} diff --git a/style/dashboard_tile.scss b/style/dashboard_tile.scss new file mode 100644 index 0000000..925a985 --- /dev/null +++ b/style/dashboard_tile.scss @@ -0,0 +1,28 @@ +.dashboard-tile { + img { + width: $dashboard-tile-size; + height: $dashboard-tile-size; + border-radius: 7px; + margin-right: 20px; + } + + a { + text-decoration: none; + color: $text-controls-color; + } + + p.dashboard-tile-title { + font-size: 16px; + font-weight: bold; + margin: 0; + padding: 0; + } + + p.dashboard-tile-description { + font-size: 12px; + margin: 0; + padding: 0; + } + + margin-right: 10px; +} diff --git a/style/main.scss b/style/main.scss index 3dfb627..99faa8b 100644 --- a/style/main.scss +++ b/style/main.scss @@ -8,8 +8,11 @@ @import 'home.scss'; @import 'search.scss'; @import 'personal.scss'; +@import 'dashboard_tile.scss'; +@import 'dashboard_row.scss'; @import 'upload.scss'; @import 'error.scss'; +@import 'song_list.scss'; body { font-family: sans-serif; diff --git a/style/song_list.scss b/style/song_list.scss new file mode 100644 index 0000000..88904ab --- /dev/null +++ b/style/song_list.scss @@ -0,0 +1,124 @@ +table.song-list { + width: 100%; + border-collapse: collapse; + + tr.song-list-item { + border: solid; + border-width: 1px 0; + border-color: #303030; + position: relative; + + td { + color: $text-controls-color; + white-space: nowrap; + padding-left: 10px; + padding-right: 10px; + + a { + text-decoration: none; + color: $text-controls-color; + } + } + + a:hover { + text-decoration: underline $controls-hover-color; + } + + td.song-image { + width: 35px; + display: flex; + + img.song-image { + position: absolute; + top: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); + width: 35px; + height: 35px; + border-radius: 5px; + } + + svg.song-image-overlay { + position: absolute; + top: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); + width: 35px; + height: 35px; + border-radius: 5px; + fill: $text-controls-color; + } + + svg.song-image-overlay:hover { + fill: $controls-hover-color; + } + + svg.song-image-overlay:active { + fill: $controls-click-color; + } + } + + td.song-list-spacer { + width: 20%; + } + + td.song-list-spacer-big { + width: 40%; + } + + button { + svg.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; + } + + .hide-until-hover { + visibility: hidden; + } + + .song-playing-overlay { + background-color: rgba(0, 0, 0, 0.8); + } + } + + tr.song-list-item:first-child { + border-top: none; + } + + tr.song-list-item:last-child { + border-bottom: none; + } + + tr.song-list-item:hover { + background-color: #303030; + + .hide-until-hover { + visibility: visible; + } + + td.song-image { + svg.song-image-overlay { + background-color: rgba(0, 0, 0, 0.8); + } + } + } +} diff --git a/style/theme.scss b/style/theme.scss index a7ebc14..fe96046 100644 --- a/style/theme.scss +++ b/style/theme.scss @@ -14,3 +14,5 @@ $queue-background-color: $play-bar-background-color; $auth-inputs: #796dd4; $auth-containers: white; + +$dashboard-tile-size: 200px;