From eebd58f5a68e3416e54c42c06d91e1eb28144f02 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 11 May 2024 12:53:57 -0400 Subject: [PATCH 01/52] Add MediaType struct --- src/lib.rs | 2 ++ src/media_type.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/media_type.rs diff --git a/src/lib.rs b/src/lib.rs index 89cd04e..23bc098 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ pub mod users; pub mod search; pub mod fileserv; pub mod error_template; +pub mod media_type; + use cfg_if::cfg_if; cfg_if! { diff --git a/src/media_type.rs b/src/media_type.rs new file mode 100644 index 0000000..f97c5c2 --- /dev/null +++ b/src/media_type.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// Differentiates between different types of media +/// Used to display a short text near a corresponging image / title to indicate what type of media it is +#[derive(Serialize, Deserialize)] +pub enum MediaType { + Song, + Album, + Artist, +} + +impl ToString for MediaType { + fn to_string(&self) -> String { + match self { + MediaType::Song => "Song".to_string(), + MediaType::Album => "Album".to_string(), + MediaType::Artist => "Artist".to_string(), + } + } +} From ec43ab472d9896e6114ef95170471724cc32bf17 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 11 May 2024 13:08:05 -0400 Subject: [PATCH 02/52] Add DashboardTile component --- src/components.rs | 3 +- src/components/dashboard_tile.rs | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/components/dashboard_tile.rs diff --git a/src/components.rs b/src/components.rs index 4d0c8a5..5f7fff7 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,4 +1,5 @@ pub mod sidebar; pub mod dashboard; pub mod search; -pub mod personal; \ No newline at end of file +pub mod personal; +pub mod dashboard_tile; diff --git a/src/components/dashboard_tile.rs b/src/components/dashboard_tile.rs new file mode 100644 index 0000000..899981f --- /dev/null +++ b/src/components/dashboard_tile.rs @@ -0,0 +1,69 @@ +use leptos::leptos_dom::*; +use leptos::*; +use serde::{Deserialize, Serialize}; +use crate::media_type::MediaType; + +/// Info representing what will be displayed in a dashboard tile +#[derive(Serialize, Deserialize)] +pub struct DashboardTile { + pub image_path: String, + pub title: String, + pub media_type: Option, + pub artist: Option, +} + +impl DashboardTile { + pub fn new(image_path: String, title: String, media_type: Option, artist: Option) -> Self { + Self { + image_path, + title, + media_type, + artist: artist.map(|artist| artist.to_string()), + } + } + + /// Get the description of the dashboard tile + /// Will display the media type, and the artist if it is available and relevant + pub fn description(&self) -> String { + match self.media_type { + Some(MediaType::Song) => { + if let Some(artist) = &self.artist { + format!("{} • {}", MediaType::Song.to_string(), artist) + } else { + MediaType::Song.to_string() + } + }, + Some(MediaType::Album) => { + if let Some(artist) = &self.artist { + format!("{} • {}", MediaType::Album.to_string(), artist) + } else { + MediaType::Album.to_string() + } + }, + Some(MediaType::Artist) => { + MediaType::Artist.to_string() + }, + None => { + if let Some(artist) = &self.artist { + artist.to_string() + } else { + "".to_string() + } + } + } + } +} + +impl IntoView for DashboardTile { + fn into_view(self) -> View { + let description = self.description(); + + view! { +
+ dashboard-tile +

{self.title}

+

{description}

+
+ }.into_view() + } +} From b1cc0f156c2bad7c2070b0da2e02011c8caeb914 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 11 May 2024 13:08:32 -0400 Subject: [PATCH 03/52] Add styling for dashboard tile --- style/dashboard_tile.scss | 23 +++++++++++++++++++++++ style/main.scss | 1 + style/theme.scss | 2 ++ 3 files changed, 26 insertions(+) create mode 100644 style/dashboard_tile.scss diff --git a/style/dashboard_tile.scss b/style/dashboard_tile.scss new file mode 100644 index 0000000..6a25a21 --- /dev/null +++ b/style/dashboard_tile.scss @@ -0,0 +1,23 @@ +.dashboard-tile { + img { + width: $dashboard-tile-size; + height: $dashboard-tile-size; + border-radius: 7px; + margin-right: 20px; + } + + 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 7793e25..ea1d847 100644 --- a/style/main.scss +++ b/style/main.scss @@ -8,6 +8,7 @@ @import 'home.scss'; @import 'search.scss'; @import 'personal.scss'; +@import 'dashboard_tile.scss'; body { font-family: sans-serif; 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; From f4908ad3b301658b8e1df8bde71bb3f08c8c19fa Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 11 May 2024 13:16:02 -0400 Subject: [PATCH 04/52] Add DashboardRow component --- src/components.rs | 1 + src/components/dashboard_row.rs | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/components/dashboard_row.rs diff --git a/src/components.rs b/src/components.rs index 5f7fff7..6f919fa 100644 --- a/src/components.rs +++ b/src/components.rs @@ -3,3 +3,4 @@ pub mod dashboard; pub mod search; pub mod personal; pub mod dashboard_tile; +pub mod dashboard_row; diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs new file mode 100644 index 0000000..2917b86 --- /dev/null +++ b/src/components/dashboard_row.rs @@ -0,0 +1,39 @@ +use leptos::leptos_dom::*; +use leptos::*; +use serde::{Deserialize, Serialize}; +use crate::components::dashboard_tile::DashboardTile; + +/// A row of dashboard tiles, with a title +#[derive(Serialize, Deserialize)] +pub struct DashboardRow { + pub title: String, + pub tiles: Vec, +} + +impl DashboardRow { + pub fn new(title: String, tiles: Vec) -> Self { + Self { + title, + tiles, + } + } +} + +impl IntoView for DashboardRow { + fn into_view(self) -> View { + view! { +
+

{self.title}

+
    + {self.tiles.into_iter().map(|tile_info| { + view! { +
  • + { tile_info } +
  • + } + }).collect::>()} +
+
+ }.into_view() + } +} From 683f979bc7ce26b0644be59d226e655c10468af2 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 11 May 2024 13:16:31 -0400 Subject: [PATCH 05/52] Add styling for dashboard row --- style/dashboard_row.scss | 12 ++++++++++++ style/main.scss | 1 + 2 files changed, 13 insertions(+) create mode 100644 style/dashboard_row.scss diff --git a/style/dashboard_row.scss b/style/dashboard_row.scss new file mode 100644 index 0000000..2eb1041 --- /dev/null +++ b/style/dashboard_row.scss @@ -0,0 +1,12 @@ +.dashboard-tile-row { + ul { + display: flex; + + 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/main.scss b/style/main.scss index ea1d847..841e88a 100644 --- a/style/main.scss +++ b/style/main.scss @@ -9,6 +9,7 @@ @import 'search.scss'; @import 'personal.scss'; @import 'dashboard_tile.scss'; +@import 'dashboard_row.scss'; body { font-family: sans-serif; From af66381f5f354afd6a9d9b44196292af2755be28 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 11 May 2024 15:23:49 -0400 Subject: [PATCH 06/52] Implement dashboard row sideways scrolling --- src/components/dashboard_row.rs | 61 +++++++++++++++++++++++++++++++-- style/dashboard_row.scss | 31 +++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs index 2917b86..4134161 100644 --- a/src/components/dashboard_row.rs +++ b/src/components/dashboard_row.rs @@ -1,7 +1,9 @@ +use leptos::html::Ul; use leptos::leptos_dom::*; use leptos::*; use serde::{Deserialize, Serialize}; use crate::components::dashboard_tile::DashboardTile; +use leptos_icons::*; /// A row of dashboard tiles, with a title #[derive(Serialize, Deserialize)] @@ -21,10 +23,65 @@ impl DashboardRow { impl IntoView for DashboardRow { fn into_view(self) -> View { + let list_ref = create_node_ref::
    (); + + // 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() { + 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)) + 15.0; + 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() { + 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) + 15.0; + 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"); + } + }; + view! {
    -

    {self.title}

    -
      +
      +

      {self.title}

      +
      + + +
      +
      +
        {self.tiles.into_iter().map(|tile_info| { view! {
      • diff --git a/style/dashboard_row.scss b/style/dashboard_row.scss index 2eb1041..1dea8d9 100644 --- a/style/dashboard_row.scss +++ b/style/dashboard_row.scss @@ -1,6 +1,37 @@ .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; li { list-style-type: none; From 7f4108e1c5c539bfd85efd3784d20f5beb8d02f0 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 30 Jul 2024 21:02:48 -0400 Subject: [PATCH 07/52] Add conversion from SongData to DashboardTile --- src/songdata.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/songdata.rs b/src/songdata.rs index 23ce415..9e08942 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -1,4 +1,6 @@ use crate::models::{Album, Artist, Song}; +use crate::components::dashboard_tile::DashboardTile; +use crate::media_type::MediaType; use time::Date; @@ -95,3 +97,14 @@ impl TryInto for SongData { }) } } + +impl Into for SongData { + fn into(self) -> DashboardTile { + DashboardTile { + image_path: self.image_path, + title: self.title, + media_type: Some(MediaType::Song), + artist: Some(Artist::display_list(&self.artists)), + } + } +} From fa811350ae3b2f72faa8dd39b5759bdef59d23f8 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 30 Jul 2024 21:06:23 -0400 Subject: [PATCH 08/52] Add leptos_use crate --- Cargo.lock | 132 +++++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 + 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bc5c0a..0a6bd5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,7 +308,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c878c71c2821aa2058722038a59a67583a4240524687c6028571c9b395ded61f" dependencies = [ - "darling", + "darling 0.14.4", "proc-macro2", "quote", "syn 1.0.109", @@ -377,6 +377,15 @@ dependencies = [ "half", ] +[[package]] +name = "codee" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af40247be877a1e3353fb406aa27ab3ef4bd3ff18cef91e75e667bfa3fde701d" +dependencies = [ + "thiserror", +] + [[package]] name = "collection_literals" version = "1.0.1" @@ -499,8 +508,18 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core 0.20.10", + "darling_macro 0.20.10", ] [[package]] @@ -513,21 +532,46 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.58", +] + [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core 0.20.10", + "quote", + "syn 2.0.58", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -541,6 +585,18 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "default-struct-builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fa90da96b8fd491f5754d1f7a731f73921e3b7aa0ce333c821a0e43666ac14" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "deranged" version = "0.3.11" @@ -862,7 +918,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-sink", - "gloo-utils", + "gloo-utils 0.2.0 (git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380)", "http", "js-sys", "pin-project", @@ -874,6 +930,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -1338,6 +1419,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos-use" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac79c02d0e2998569116aa36d26fd00bfa8cadbe8cb630eb771b4d1676412a16" +dependencies = [ + "async-trait", + "cfg-if", + "codee", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers", + "gloo-utils 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "js-sys", + "lazy_static", + "leptos", + "paste", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "leptos_axum" version = "0.6.10" @@ -1587,6 +1692,7 @@ dependencies = [ "icondata", "lazy_static", "leptos", + "leptos-use", "leptos_axum", "leptos_icons", "leptos_meta", @@ -2477,6 +2583,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.5.0" @@ -2580,18 +2692,18 @@ checksum = "384595c11a4e2969895cad5a8c4029115f5ab956a9e5ef4de79d11a426e5f20c" [[package]] name = "thiserror" -version = "1.0.58" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.58" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 953da57..b162619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ multer = { version = "3.0.0", optional = true } log = { version = "0.4.21", optional = true } flexi_logger = { version = "0.28.0", optional = true, default-features = false } web-sys = "0.3.69" +leptos-use = "0.11.3" [patch.crates-io] gloo-net = { git = "https://github.com/rustwasm/gloo.git", rev = "a823fab7ecc4068e9a28bd669da5eaf3f0a56380" } @@ -74,6 +75,7 @@ ssr = [ "multer", "log", "flexi_logger", + "leptos-use/ssr", ] # Defines a size-optimized profile for the WASM bundle in release mode From 9d6013b8a4d8a86d02782d49108fa6885afd0446 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 30 Jul 2024 21:35:44 -0400 Subject: [PATCH 09/52] Move dashboard tile row left spacing to margin-left --- src/components/dashboard_row.rs | 4 ++-- style/dashboard_row.scss | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs index 4134161..10f1ee9 100644 --- a/src/components/dashboard_row.rs +++ b/src/components/dashboard_row.rs @@ -36,7 +36,7 @@ impl IntoView for DashboardRow { 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)) + 15.0; + 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"); @@ -56,7 +56,7 @@ impl IntoView for DashboardRow { 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) + 15.0; + 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"); diff --git a/style/dashboard_row.scss b/style/dashboard_row.scss index 1dea8d9..8bbb2c9 100644 --- a/style/dashboard_row.scss +++ b/style/dashboard_row.scss @@ -32,6 +32,8 @@ display: flex; overflow-x: hidden; scroll-behavior: smooth; + margin-left: 40px; + padding-inline-start: 0; li { list-style-type: none; From eab011070d7975b228347ef9cc91533293ce480a Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 30 Jul 2024 21:46:59 -0400 Subject: [PATCH 10/52] Don't track scroll_element in scroll button handlers --- src/components/dashboard_row.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs index 10f1ee9..a85159c 100644 --- a/src/components/dashboard_row.rs +++ b/src/components/dashboard_row.rs @@ -29,7 +29,7 @@ impl IntoView for DashboardRow { // 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() { + 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; @@ -49,7 +49,7 @@ impl IntoView for DashboardRow { }; let scroll_right = move |_| { - if let Some(scroll_element) = list_ref.get() { + 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; From 53cde3bed6c4051f137574628e12aa820bd46eae Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 30 Jul 2024 21:47:55 -0400 Subject: [PATCH 11/52] Hide scroll buttons when at end of scroll bar --- src/components/dashboard_row.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs index a85159c..1690d6d 100644 --- a/src/components/dashboard_row.rs +++ b/src/components/dashboard_row.rs @@ -1,6 +1,7 @@ use leptos::html::Ul; use leptos::leptos_dom::*; use leptos::*; +use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn}; use serde::{Deserialize, Serialize}; use crate::components::dashboard_tile::DashboardTile; use leptos_icons::*; @@ -68,15 +69,38 @@ impl IntoView for DashboardRow { } }; + 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! {

        {self.title}

        - -
        From 2c71e065b7c1a3bff400824daa5738dd3d5c141b Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Fri, 4 Oct 2024 15:14:32 -0400 Subject: [PATCH 12/52] Update tower and tower-http Remove gloo-net override --- Cargo.lock | 77 ++++++++++++++++++++++++++++++++++++------------------ Cargo.toml | 7 ++--- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23984ed..079c4ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", From a43955726abcdd7077374c645416c10718472b4a Mon Sep 17 00:00:00 2001 From: Carter Bertolini Date: Fri, 4 Oct 2024 17:33:51 -0400 Subject: [PATCH 13/52] Setup routing for image and audio assets based on environment variables --- src/fileserv.rs | 30 ++++++++++++++++++++++++++++++ src/main.rs | 6 ++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/fileserv.rs b/src/fileserv.rs index 4fe7a30..dbfccc6 100644 --- a/src/fileserv.rs +++ b/src/fileserv.rs @@ -12,6 +12,7 @@ cfg_if! { if #[cfg(feature = "ssr")] { use tower_http::services::ServeDir; use leptos::*; use crate::app::App; + use std::str::FromStr; pub async fn file_and_error_handler(uri: Uri, State(options): State, req: Request) -> AxumResponse { let root = options.site_root.clone(); @@ -27,6 +28,7 @@ cfg_if! { if #[cfg(feature = "ssr")] { pub async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); + // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` // This path is relative to the cargo root match ServeDir::new(root).oneshot(req).await.ok() { @@ -37,4 +39,32 @@ cfg_if! { if #[cfg(feature = "ssr")] { )), } } + + pub enum AssetType { + Audio, + Image, + } + + pub async fn get_asset_file(filename: String, asset_type: AssetType) -> Result, (StatusCode, String)> { + const DEFAULT_AUDIO_PATH: &str = "assets/audio"; + const DEFAULT_IMAGE_PATH: &str = "assets/images"; + + let root = match asset_type { + AssetType::Audio => std::env::var("LIBRETUNES_AUDIO_PATH").unwrap_or(DEFAULT_AUDIO_PATH.to_string()), + AssetType::Image => std::env::var("LIBRETUNES_IMAGE_PATH").unwrap_or(DEFAULT_IMAGE_PATH.to_string()), + }; + + // Create a Uri from the filename + // ServeDir expects a leading `/` + let uri = Uri::from_str(format!("/{}", filename).as_str()); + + match uri { + Ok(uri) => get_static_file(uri, root.as_str()).await, + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Attempted to serve an invalid file"), + )), + } + } + }} diff --git a/src/main.rs b/src/main.rs index 6e50edf..a4efbee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,11 +14,11 @@ extern crate diesel_migrations; #[cfg(feature = "ssr")] #[tokio::main] async fn main() { - use axum::{routing::get, Router}; + use axum::{routing::get, Router, extract::Path}; use leptos::*; use leptos_axum::{generate_route_list, LeptosRoutes}; use libretunes::app::*; - use libretunes::fileserv::{file_and_error_handler, get_static_file}; + use libretunes::fileserv::{file_and_error_handler, get_asset_file, get_static_file, AssetType}; use axum_login::tower_sessions::SessionManagerLayer; use tower_sessions_redis_store::{fred::prelude::*, RedisStore}; use axum_login::AuthManagerLayerBuilder; @@ -60,6 +60,8 @@ async fn main() { let app = Router::new() .leptos_routes(&leptos_options, routes, App) + .route("/assets/audio/:song", get(|Path(song) : Path| get_asset_file(song, AssetType::Audio))) + .route("/assets/images/:image", get(|Path(image) : Path| get_asset_file(image, AssetType::Image))) .route("/assets/*uri", get(|uri| get_static_file(uri, ""))) .layer(auth_layer) .fallback(file_and_error_handler) From 2bb9b3bdd74e535464a7fdd7cb6ce74e5c6d8ef5 Mon Sep 17 00:00:00 2001 From: Aidan Westphal Date: Fri, 4 Oct 2024 23:19:07 +0000 Subject: [PATCH 14/52] Page Title Updates on Playing Song --- src/app.rs | 3 ++- src/playbar.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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/playbar.rs b/src/playbar.rs index f0055e4..69ac98f 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,22 @@ 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| { + match play_status.queue.front() { + Some(song_data) => song_data.title.clone(), + None => "LibreTunes".to_owned(), + } + }) + }); + 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 { From 51858593c26d53a593befd12d39815079558052a Mon Sep 17 00:00:00 2001 From: Aidan Westphal <kylesteine1@gmail.com> Date: Fri, 4 Oct 2024 23:56:12 +0000 Subject: [PATCH 15/52] Updated Title Display --- src/playbar.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/playbar.rs b/src/playbar.rs index 69ac98f..d113b7a 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -466,12 +466,11 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView { pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView { let title = create_memo(move |_| { play_status.with(|play_status| { - match play_status.queue.front() { - Some(song_data) => song_data.title.clone(), - None => "LibreTunes".to_owned(), - } - }) - }); + 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 /> } From 553e24800b84b56d5acf6709e10e397b95225017 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 6 Oct 2024 15:35:18 -0400 Subject: [PATCH 16/52] Refactor DashboardTile into trait --- src/components/dashboard_row.rs | 8 ++-- src/components/dashboard_tile.rs | 70 +++++++------------------------- 2 files changed, 17 insertions(+), 61 deletions(-) diff --git a/src/components/dashboard_row.rs b/src/components/dashboard_row.rs index 1690d6d..7abd258 100644 --- a/src/components/dashboard_row.rs +++ b/src/components/dashboard_row.rs @@ -2,19 +2,17 @@ use leptos::html::Ul; use leptos::leptos_dom::*; use leptos::*; use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn}; -use serde::{Deserialize, Serialize}; use crate::components::dashboard_tile::DashboardTile; use leptos_icons::*; /// A row of dashboard tiles, with a title -#[derive(Serialize, Deserialize)] pub struct DashboardRow { pub title: String, - pub tiles: Vec<DashboardTile>, + pub tiles: Vec<Box<dyn DashboardTile>>, } impl DashboardRow { - pub fn new(title: String, tiles: Vec<DashboardTile>) -> Self { + pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self { Self { title, tiles, @@ -109,7 +107,7 @@ impl IntoView for DashboardRow { {self.tiles.into_iter().map(|tile_info| { view! { <li> - { tile_info } + { tile_info.into_view() } </li> } }).collect::<Vec<_>>()} diff --git a/src/components/dashboard_tile.rs b/src/components/dashboard_tile.rs index 899981f..354aad5 100644 --- a/src/components/dashboard_tile.rs +++ b/src/components/dashboard_tile.rs @@ -1,68 +1,26 @@ use leptos::leptos_dom::*; use leptos::*; -use serde::{Deserialize, Serialize}; -use crate::media_type::MediaType; -/// Info representing what will be displayed in a dashboard tile -#[derive(Serialize, Deserialize)] -pub struct DashboardTile { - pub image_path: String, - pub title: String, - pub media_type: Option<MediaType>, - pub artist: Option<String>, +pub trait DashboardTile { + fn image_path(&self) -> String; + fn title(&self) -> String; + fn link(&self) -> String; + fn description(&self) -> Option<String> { None } } -impl DashboardTile { - pub fn new(image_path: String, title: String, media_type: Option<MediaType>, artist: Option<String>) -> Self { - Self { - image_path, - title, - media_type, - artist: artist.map(|artist| artist.to_string()), - } - } - - /// Get the description of the dashboard tile - /// Will display the media type, and the artist if it is available and relevant - pub fn description(&self) -> String { - match self.media_type { - Some(MediaType::Song) => { - if let Some(artist) = &self.artist { - format!("{} • {}", MediaType::Song.to_string(), artist) - } else { - MediaType::Song.to_string() - } - }, - Some(MediaType::Album) => { - if let Some(artist) = &self.artist { - format!("{} • {}", MediaType::Album.to_string(), artist) - } else { - MediaType::Album.to_string() - } - }, - Some(MediaType::Artist) => { - MediaType::Artist.to_string() - }, - None => { - if let Some(artist) = &self.artist { - artist.to_string() - } else { - "".to_string() - } - } - } - } -} - -impl IntoView for DashboardTile { +impl IntoView for &dyn DashboardTile { fn into_view(self) -> View { - let description = self.description(); + let link = self.link(); view! { <div class="dashboard-tile"> - <img src={self.image_path} alt="dashboard-tile" /> - <p class="dashboard-tile-title">{self.title}</p> - <p class="dashboard-tile-description">{description}</p> + <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() } From f02a22d80597f918ec2a6cf60523fe45f1321c65 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 6 Oct 2024 15:35:52 -0400 Subject: [PATCH 17/52] Format text inside dashboard tiles normally --- style/dashboard_tile.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/style/dashboard_tile.scss b/style/dashboard_tile.scss index 6a25a21..925a985 100644 --- a/style/dashboard_tile.scss +++ b/style/dashboard_tile.scss @@ -6,6 +6,11 @@ margin-right: 20px; } + a { + text-decoration: none; + color: $text-controls-color; + } + p.dashboard-tile-title { font-size: 16px; font-weight: bold; From 097b1fc491f35d4fa8198cdd16e414ee1eacdb1d Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 6 Oct 2024 15:37:26 -0400 Subject: [PATCH 18/52] impl DashboardTile for Songdata --- src/songdata.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/songdata.rs b/src/songdata.rs index 99464c8..6851a85 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -1,6 +1,5 @@ use crate::models::{Album, Artist, Song}; use crate::components::dashboard_tile::DashboardTile; -use crate::media_type::MediaType; use time::Date; @@ -63,13 +62,20 @@ impl TryInto<Song> for SongData { } } -impl Into<DashboardTile> for SongData { - fn into(self) -> DashboardTile { - DashboardTile { - image_path: self.image_path, - title: self.title, - media_type: Some(MediaType::Song), - artist: Some(Artist::display_list(&self.artists)), - } +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))) } } From c18bf277b94995dab53c1fa08a93b59e6cb92d29 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 6 Oct 2024 15:49:08 -0400 Subject: [PATCH 19/52] Create AlbumData, impl DashboardTile --- src/albumdata.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 40 insertions(+) create mode 100644 src/albumdata.rs 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<Artist>, + /// Album release date + pub release_date: Option<Date>, + /// 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<String> { + Some(format!("Album • {}", Artist::display_list(&self.artists))) + } +} diff --git a/src/lib.rs b/src/lib.rs index 19d61b2..a347039 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod auth; pub mod songdata; +pub mod albumdata; pub mod playstatus; pub mod playbar; pub mod database; From c678d93661d4852cbcdee06bdd75eee24fc9ec49 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 6 Oct 2024 15:49:19 -0400 Subject: [PATCH 20/52] Create ArtistData, impl DashboardTile --- src/artistdata.rs | 32 ++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 33 insertions(+) create mode 100644 src/artistdata.rs 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/lib.rs b/src/lib.rs index a347039..474fb99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod auth; pub mod songdata; pub mod albumdata; +pub mod artistdata; pub mod playstatus; pub mod playbar; pub mod database; From 134b425ce603e02930e4077499ac29f346f37032 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 6 Oct 2024 15:50:21 -0400 Subject: [PATCH 21/52] Remove unused MediaType --- src/lib.rs | 1 - src/media_type.rs | 20 -------------------- 2 files changed, 21 deletions(-) delete mode 100644 src/media_type.rs diff --git a/src/lib.rs b/src/lib.rs index 474fb99..95ac8ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,6 @@ pub mod users; pub mod search; pub mod fileserv; pub mod error_template; -pub mod media_type; pub mod api; pub mod upload; pub mod util; diff --git a/src/media_type.rs b/src/media_type.rs deleted file mode 100644 index f97c5c2..0000000 --- a/src/media_type.rs +++ /dev/null @@ -1,20 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Differentiates between different types of media -/// Used to display a short text near a corresponging image / title to indicate what type of media it is -#[derive(Serialize, Deserialize)] -pub enum MediaType { - Song, - Album, - Artist, -} - -impl ToString for MediaType { - fn to_string(&self) -> String { - match self { - MediaType::Song => "Song".to_string(), - MediaType::Album => "Album".to_string(), - MediaType::Artist => "Artist".to_string(), - } - } -} From 0550b18d7720755edfa75c3a1763449fb4941059 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 11 Oct 2024 13:22:57 -0400 Subject: [PATCH 22/52] Add SongList component --- src/components.rs | 1 + src/components/song_list.rs | 157 ++++++++++++++++++++++++++++++++++++ src/songdata.rs | 1 + style/main.scss | 1 + style/song_list.scss | 124 ++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 src/components/song_list.rs create mode 100644 style/song_list.scss diff --git a/src/components.rs b/src/components.rs index 29ac621..893727c 100644 --- a/src/components.rs +++ b/src/components.rs @@ -5,3 +5,4 @@ pub mod personal; pub mod dashboard_tile; pub mod dashboard_row; pub mod upload; +pub mod song_list; 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/songdata.rs b/src/songdata.rs index 6851a85..36e5679 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -6,6 +6,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(Clone)] pub struct SongData { /// Song id pub id: i32, diff --git a/style/main.scss b/style/main.scss index 1a80b34..042e605 100644 --- a/style/main.scss +++ b/style/main.scss @@ -11,6 +11,7 @@ @import 'dashboard_tile.scss'; @import 'dashboard_row.scss'; @import 'upload.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); + } + } + } +} From b222b7911ff68e6019abea92f8e4ac20e37c2fbd Mon Sep 17 00:00:00 2001 From: Carter Bertolini <carterbertolini@gmail.com> Date: Fri, 11 Oct 2024 16:53:08 -0400 Subject: [PATCH 23/52] Add example environment variables and update docker-compose --- .env.example | 3 +++ docker-compose.yml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.env.example b/.env.example index ef8c8dd..893f587 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ DATABASE_URL=postgresql://libretunes:password@localhost:5432/libretunes # POSTGRES_HOST=localhost # POSTGRES_PORT=5432 # POSTGRES_DB=libretunes + +LIBRETUNES_AUDIO_PATH=assets/audio +LIBRETUNES_IMAGE_PATH=assets/images diff --git a/docker-compose.yml b/docker-compose.yml index c2d0865..fffc2b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} + LIBRETUNES_AUDIO_PATH: /site/assets/audio + LIBRETUNES_IMAGE_PATH: /site/assets/images volumes: - libretunes-audio:/site/audio depends_on: From e90ea56d149b9907618dc7a02bc10d50c03fd004 Mon Sep 17 00:00:00 2001 From: Carter Bertolini <carterbertolini@gmail.com> Date: Fri, 11 Oct 2024 17:16:51 -0400 Subject: [PATCH 24/52] Remove TODO --- src/songdata.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/songdata.rs b/src/songdata.rs index 61e263f..d8db885 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -47,7 +47,6 @@ impl TryInto<Song> for SongData { track: self.track, duration: self.duration, release_date: self.release_date, - // TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35 storage_path: self.song_path, // Note that if the source of the image_path was the album, the image_path From f39d93bd0c66e551762e2ef58aac1061c4ea6544 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Tue, 15 Oct 2024 23:22:15 -0400 Subject: [PATCH 25/52] Change audio and image path Docker comopse settings Add volume for images --- docker-compose.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fffc2b5..2a1ea56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,11 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} - LIBRETUNES_AUDIO_PATH: /site/assets/audio - LIBRETUNES_IMAGE_PATH: /site/assets/images + LIBRETUNES_AUDIO_PATH: /assets/audio + LIBRETUNES_IMAGE_PATH: /assets/images volumes: - - libretunes-audio:/site/audio + - libretunes-audio:/assets/audio + - libretunes-images:/assets/images depends_on: - redis - postgres From 88e2a229a422128862b1ca48738377b2debdcb59 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Mon, 21 Oct 2024 22:54:26 -0400 Subject: [PATCH 26/52] 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 <ethan@girouard.com> Date: Mon, 21 Oct 2024 22:57:27 -0400 Subject: [PATCH 27/52] 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<E: Display + 'static>( + #[prop(optional, into, default="An Error Occurred".into())] + title: TextProp, + #[prop(optional, into)] + message: TextProp, + #[prop(optional, into)] + error: Option<ServerFnError<E>>, +) -> impl IntoView { + view! { + <div class="error-container home-component"> + <div class="error-header"> + <Icon icon=icondata::BiErrorSolid /> + <h1>{title}</h1> + </div> + <p>{message}</p> + <p>{error.map(|error| format!("{}", error))}</p> + </div> + } +} 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 14aa4d236ea48d0a7e0a71d2cf090efbf6708337 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 25 Oct 2024 18:28:45 +0000 Subject: [PATCH 28/52] Add libretunes-images to volumes --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 2a1ea56..c3d466d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,5 +53,6 @@ services: volumes: libretunes-audio: + libretunes-images: libretunes-redis: libretunes-postgres: From 23bfb510c15369d809b1ac743935cb4d5eceabe9 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 1 Nov 2024 13:05:47 -0400 Subject: [PATCH 29/52] 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<i64>) -> Result<Vec<(SystemTime, SongData)>, ServerFnError> { + let mut db_con = get_db_conn(); + + // Get the ids of the most recent songs listened to + let history_items: Vec<i32> = + 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<Album>, Option<Artist>, 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<i32, (SystemTime, SongData)> = 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<i64>) + -> Result<Vec<(i64, SongData)>, 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<i32, i64> = history_counts.into_iter().collect(); + let history_song_ids = history_counts.iter().map(|(song_id, _)| *song_id).collect::<Vec<i32>>(); + + // Get the song data for the songs listened to in the date range + let history_songs: Vec<(Song, Option<Album>, Option<Artist>, 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<i32, (i64, SongData)> = HashMap::with_capacity(history_counts.len()); + + for (song, album, artist, like, dislike) in history_songs { + let song_id = song.id + .ok_or(ServerFnError::ServerError::<NoCustomError>("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::<NoCustomError>("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<i64>) + -> Result<Vec<(i64, Artist)>, 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 <ethan@girouard.com> Date: Fri, 1 Nov 2024 14:26:38 -0400 Subject: [PATCH 30/52] 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<i64>) -> Result<Vec<(S 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())); + .unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())); let songdata = SongData { id: song_id, @@ -226,7 +226,7 @@ pub async fn top_songs(for_user_id: i32, start_date: SystemTime, end_date: Syste 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())); + .unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())); let songdata = SongData { id: song_id, From e66b5e4976acf9634bfaf16b58340bf292eb18fe Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 1 Nov 2024 14:36:38 -0400 Subject: [PATCH 31/52] 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<Vec<SongData>>) -> impl IntoView { let playing = first_song.into(); first_song = false; + let extra = Option::<()>::None; + view! { - <SongListItem song={song.clone()} song_playing=playing /> + <SongListItem song={song.clone()} song_playing=playing extra /> } }).collect::<Vec<_>>() }) @@ -29,7 +31,33 @@ pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView { } #[component] -pub fn SongListItem(song: SongData, song_playing: MaybeSignal<bool>) -> impl IntoView { +pub fn SongListExtra<T>(songs: MaybeSignal<Vec<(SongData, T)>>) -> impl IntoView where + T: Clone + IntoView + 'static +{ + view! { + <table class="song-list"> + { + songs.with(|songs| { + let mut first_song = true; + + songs.iter().map(|(song, extra)| { + let playing = first_song.into(); + first_song = false; + + view! { + <SongListItem song={song.clone()} song_playing=playing extra=Some(extra.clone()) /> + } + }).collect::<Vec<_>>() + }) + } + </table> + } +} + +#[component] +pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>) -> 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<bool>) -> impl Int <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> + {extra.map(|extra| view! { + <td class="song-list-spacer"></td> + <td>{extra}</td> + })} </tr> } } From 81b1490cce6cd2c05fe7f3459e76a288f0a4ad38 Mon Sep 17 00:00:00 2001 From: Carter Bertolini <carterbertolini@gmail.com> Date: Fri, 1 Nov 2024 16:37:22 -0400 Subject: [PATCH 32/52] Create playlist database migration --- .../down.sql | 5 +++++ .../up.sql | 17 ++++++++++++++ src/schema.rs | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 migrations/2024-10-22-212759_create_playlist_tables/down.sql create mode 100644 migrations/2024-10-22-212759_create_playlist_tables/up.sql diff --git a/migrations/2024-10-22-212759_create_playlist_tables/down.sql b/migrations/2024-10-22-212759_create_playlist_tables/down.sql new file mode 100644 index 0000000..defde9d --- /dev/null +++ b/migrations/2024-10-22-212759_create_playlist_tables/down.sql @@ -0,0 +1,5 @@ +DROP INDEX playlists_owner_idx; +DROP TABLE playlists; + +DROP INDEX playlist_songs_playlist_idx; +DROP TABLE playlist_songs; diff --git a/migrations/2024-10-22-212759_create_playlist_tables/up.sql b/migrations/2024-10-22-212759_create_playlist_tables/up.sql new file mode 100644 index 0000000..cf35cb8 --- /dev/null +++ b/migrations/2024-10-22-212759_create_playlist_tables/up.sql @@ -0,0 +1,17 @@ +CREATE TABLE playlists ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + name TEXT NOT NULL +); + +CREATE INDEX playlists_owner_idx ON playlists(owner_id); + +CREATE TABLE playlist_songs ( + playlist_id INTEGER REFERENCES playlists(id) ON DELETE CASCADE NOT NULL, + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (playlist_id, song_id) +); + +CREATE INDEX playlist_songs_playlist_idx ON playlist_songs(playlist_id); diff --git a/src/schema.rs b/src/schema.rs index 29401e7..31aebd6 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -39,6 +39,23 @@ diesel::table! { } } +diesel::table! { + playlist_songs (playlist_id, song_id) { + playlist_id -> Int4, + song_id -> Int4, + } +} + +diesel::table! { + playlists (id) { + id -> Int4, + created_at -> Timestamp, + updated_at -> Timestamp, + owner_id -> Int4, + name -> Text, + } +} + diesel::table! { song_artists (song_id, artist_id) { song_id -> Int4, @@ -95,6 +112,9 @@ diesel::table! { diesel::joinable!(album_artists -> albums (album_id)); diesel::joinable!(album_artists -> artists (artist_id)); +diesel::joinable!(playlist_songs -> playlists (playlist_id)); +diesel::joinable!(playlist_songs -> songs (song_id)); +diesel::joinable!(playlists -> users (owner_id)); diesel::joinable!(song_artists -> artists (artist_id)); diesel::joinable!(song_artists -> songs (song_id)); diesel::joinable!(song_dislikes -> songs (song_id)); @@ -111,6 +131,8 @@ diesel::allow_tables_to_appear_in_same_query!( artists, friend_requests, friendships, + playlist_songs, + playlists, song_artists, song_dislikes, song_history, From 0335e3d255cb2c573d1be5364507ac95ba07956c Mon Sep 17 00:00:00 2001 From: Carter Bertolini <carterbertolini@gmail.com> Date: Fri, 1 Nov 2024 16:48:56 -0400 Subject: [PATCH 33/52] Created model --- src/models.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/models.rs b/src/models.rs index b96c8d2..fb96465 100644 --- a/src/models.rs +++ b/src/models.rs @@ -626,3 +626,24 @@ pub struct HistoryEntry { /// The id of the song that was listened to pub song_id: i32, } + +/// Model for a playlist +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::playlists))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize)] +pub struct Playlist { + /// A unique id for the playlist + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + pub id: Option<i32>, + /// The time the playlist was created + #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] + pub created_at: Option<SystemTime>, + /// The time the playlist was last updated + #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] + pub updated_at: Option<SystemTime>, + /// The id of the user who owns the playlist + pub owner_id: i32, + /// The name of the playlist + pub name: String, +} From a70de76c4d3a796ec26fd63a8c4c4178f4587759 Mon Sep 17 00:00:00 2001 From: Daniel Miller <daniel@matv.io> Date: Sat, 2 Nov 2024 02:37:50 +0000 Subject: [PATCH 34/52] Added webp files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ab27e5e..f472188 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ playwright/.cache/ *.jpeg *.png *.gif +*.webp # Environment variables .env From 251d56786399a7fe50d77219ab41a0f2afd3d605 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 3 Nov 2024 16:52:15 -0500 Subject: [PATCH 35/52] Return logged in user from login endpoint --- src/auth.rs | 10 ++++++---- src/pages/login.rs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 37f861f..c0751d1 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -57,7 +57,7 @@ pub async fn signup(new_user: User) -> Result<(), ServerFnError> { /// Takes in a username or email and a password in plaintext /// Returns a Result with a boolean indicating if the login was successful #[server(endpoint = "login")] -pub async fn login(credentials: UserCredentials) -> Result<bool, ServerFnError> { +pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> { use crate::users::validate_user; let mut auth_session = extract::<AuthSession<AuthBackend>>().await @@ -66,12 +66,14 @@ pub async fn login(credentials: UserCredentials) -> Result<bool, ServerFnError> let user = validate_user(credentials).await .map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {}", e)))?; - if let Some(user) = user { + if let Some(mut user) = user { auth_session.login(&user).await .map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {}", e)))?; - Ok(true) + + user.password = None; + Ok(Some(user)) } else { - Ok(false) + Ok(None) } } diff --git a/src/pages/login.rs b/src/pages/login.rs index 62aca49..1fda5ca 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -32,12 +32,12 @@ pub fn Login() -> impl IntoView { if let Err(err) = login_result { // Handle the error here, e.g., log it or display to the user log!("Error logging in: {:?}", err); - } else if let Ok(true) = login_result { + } else if let Ok(Some(_)) = login_result { // Redirect to the login page log!("Logged in Successfully!"); leptos_router::use_navigate()("/", Default::default()); log!("Navigated to home page after login"); - } else if let Ok(false) = login_result { + } else if let Ok(None) = login_result { log!("Invalid username or password"); } }); From 43a5b519fdf8e5fb744db287d907bf59bdc5e23f Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 3 Nov 2024 16:59:23 -0500 Subject: [PATCH 36/52] Add API endpoint to get logged in user --- src/auth.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/auth.rs b/src/auth.rs index c0751d1..558bfe2 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -147,6 +147,19 @@ pub async fn get_user() -> Result<User, ServerFnError> { auth_session.user.ok_or(ServerFnError::<NoCustomError>::ServerError("User not logged in".to_string())) } +#[server(endpoint = "get_logged_in_user")] +pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> { + let auth_session = extract::<AuthSession<AuthBackend>>().await + .map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?; + + let user = auth_session.user.map(|mut user| { + user.password = None; + user + }); + + Ok(user) +} + /// 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")] From 8ac3a87c58a2e1512e4e7ec017341713e5d0dac1 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 3 Nov 2024 16:59:46 -0500 Subject: [PATCH 37/52] Add global logged in user Resource --- src/app.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app.rs b/src/app.rs index ed9fd1b..9e91878 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,12 +3,16 @@ use crate::playbar::CustomTitle; use crate::playstatus::PlayStatus; use crate::queue::Queue; use leptos::*; +use leptos::logging::*; use leptos_meta::*; use leptos_router::*; use crate::pages::login::*; use crate::pages::signup::*; use crate::error_template::{AppError, ErrorTemplate}; +use crate::auth::get_logged_in_user; +use crate::models::User; +pub type LoggedInUserResource = Resource<(), Option<User>>; #[component] pub fn App() -> impl IntoView { @@ -19,6 +23,18 @@ pub fn App() -> impl IntoView { let play_status = create_rw_signal(play_status); let upload_open = create_rw_signal(false); + // A resource that fetches the logged in user + // This will not automatically refetch, so any login/logout related code + // should call `refetch` on this resource + let logged_in_user: LoggedInUserResource = create_resource(|| (), |_| async { + get_logged_in_user().await + .inspect_err(|e| { + error!("Error getting logged in user: {:?}", e); + }) + .ok() + .flatten() + }); + view! { // injects a stylesheet into the document <head> // id=leptos means cargo-leptos will hot-reload this stylesheet From 2b380d77876cd7aef245a4ab1e2e5cf26b70ae9c Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Sun, 3 Nov 2024 17:13:40 -0500 Subject: [PATCH 38/52] Refetch logged in user on login/signup --- src/app.rs | 4 ++-- src/pages/login.rs | 14 ++++++++++++-- src/pages/signup.rs | 14 +++++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9e91878..50cad40 100644 --- a/src/app.rs +++ b/src/app.rs @@ -59,8 +59,8 @@ pub fn App() -> impl IntoView { <Route path="dashboard" view=Dashboard /> <Route path="search" view=Search /> </Route> - <Route path="/login" view=Login /> - <Route path="/signup" view=Signup /> + <Route path="/login" view=move || view!{ <Login user=logged_in_user /> } /> + <Route path="/signup" view=move || view!{ <Signup user=logged_in_user /> } /> </Routes> </main> </Router> diff --git a/src/pages/login.rs b/src/pages/login.rs index 1fda5ca..585f2f0 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -3,9 +3,10 @@ use leptos::leptos_dom::*; use leptos::*; use leptos_icons::*; use crate::users::UserCredentials; +use crate::app::LoggedInUserResource; #[component] -pub fn Login() -> impl IntoView { +pub fn Login(user: LoggedInUserResource) -> impl IntoView { let (username_or_email, set_username_or_email) = create_signal("".to_string()); let (password, set_password) = create_signal("".to_string()); @@ -32,13 +33,22 @@ pub fn Login() -> impl IntoView { if let Err(err) = login_result { // Handle the error here, e.g., log it or display to the user log!("Error logging in: {:?}", err); - } else if let Ok(Some(_)) = login_result { + + // Since we're not sure what the state is, manually refetch the user + user.refetch(); + } else if let Ok(Some(login_user)) = login_result { + // Manually set the user to the new user, avoiding a refetch + user.set(Some(login_user)); + // Redirect to the login page log!("Logged in Successfully!"); leptos_router::use_navigate()("/", Default::default()); log!("Navigated to home page after login"); } else if let Ok(None) = login_result { log!("Invalid username or password"); + + // User could be already logged in or not, so refetch the user + user.refetch(); } }); }; diff --git a/src/pages/signup.rs b/src/pages/signup.rs index f02dfab..8e9a0ac 100644 --- a/src/pages/signup.rs +++ b/src/pages/signup.rs @@ -3,9 +3,10 @@ use crate::models::User; use leptos::leptos_dom::*; use leptos::*; use leptos_icons::*; +use crate::app::LoggedInUserResource; #[component] -pub fn Signup() -> impl IntoView { +pub fn Signup(user: LoggedInUserResource) -> impl IntoView { let (username, set_username) = create_signal("".to_string()); let (email, set_email) = create_signal("".to_string()); let (password, set_password) = create_signal("".to_string()); @@ -19,7 +20,7 @@ pub fn Signup() -> impl IntoView { let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); - let new_user = User { + let mut new_user = User { id: None, username: username.get(), email: email.get(), @@ -30,10 +31,17 @@ pub fn Signup() -> impl IntoView { log!("new user: {:?}", new_user); spawn_local(async move { - if let Err(err) = signup(new_user).await { + if let Err(err) = signup(new_user.clone()).await { // Handle the error here, e.g., log it or display to the user log!("Error signing up: {:?}", err); + + // Since we're not sure what the state is, manually refetch the user + user.refetch(); } else { + // Manually set the user to the new user, avoiding a refetch + new_user.password = None; + user.set(Some(new_user)); + // Redirect to the login page log!("Signed up successfully!"); leptos_router::use_navigate()("/", Default::default()); From 414489d1bedc890a9bc7fdda818326e061f6e964 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Mon, 4 Nov 2024 00:29:53 -0500 Subject: [PATCH 39/52] 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<i64>) - -> Result<Vec<(i64, Artist)>, ServerFnError> + -> Result<Vec<(i64, ArtistData)>, 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 <ethan@girouard.com> Date: Mon, 4 Nov 2024 15:13:07 -0500 Subject: [PATCH 40/52] 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! { + <div class="loading"></div> + } +} + +/// A full page, centered loading indicator +#[component] +pub fn LoadingPage() -> impl IntoView { + view!{ + <div class="loading-page"> + <Loading /> + </div> + } +} 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 <ethan@girouard.com> Date: Mon, 4 Nov 2024 15:14:05 -0500 Subject: [PATCH 41/52] 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<Option<User>, ServerF Ok(user) } + +#[server(endpoint = "get_user_by_id")] +pub async fn get_user_by_id(user_id: i32) -> Result<Option<User>, 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 <ethan@girouard.com> Date: Mon, 4 Nov 2024 17:01:42 -0500 Subject: [PATCH 42/52] 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 <ethan@girouard.com> Date: Mon, 4 Nov 2024 17:02:59 -0500 Subject: [PATCH 43/52] 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<E: Display + 'static>( error: Option<ServerFnError<E>>, ) -> impl IntoView { view! { - <div class="error-container home-component"> + <div class="error-container"> <div class="error-header"> <Icon icon=icondata::BiErrorSolid /> <h1>{title}</h1> 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 <ethan@girouard.com> Date: Mon, 4 Nov 2024 17:11:26 -0500 Subject: [PATCH 44/52] 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<E: Display + 'static>( message: TextProp, #[prop(optional, into)] error: Option<ServerFnError<E>>, +) -> impl IntoView { + view!{ + <div class="error-container"> + <div class="error-header"> + <Icon icon=icondata::BiErrorSolid /> + <h1>{title}</h1> + </div> + <p>{message}</p> + <p>{error.map(|error| format!("{}", error))}</p> + </div> + } +} + +#[component] +pub fn Error<E: Display + 'static>( + #[prop(optional, into, default="An Error Occurred".into())] + title: TextProp, + #[prop(optional, into)] + message: TextProp, + #[prop(optional, into)] + error: Option<E>, ) -> impl IntoView { view! { <div class="error-container"> From ef5576ab3f402d710111a960855d92b695e0cefc Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Mon, 4 Nov 2024 17:15:55 -0500 Subject: [PATCH 45/52] 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 { <Route path="" view=Dashboard /> <Route path="dashboard" view=Dashboard /> <Route path="search" view=Search /> + <Route path="user/:id" view=move || view!{ <Profile logged_in_user /> } /> + <Route path="user" view=move || view!{ <Profile logged_in_user /> } /> </Route> <Route path="/login" view=move || view!{ <Login user=logged_in_user /> } /> <Route path="/signup" view=move || view!{ <Signup user=logged_in_user /> } /> @@ -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! { + <div class="profile-container home-component"> + {move || params.with(|params| { + match params.get("id").map(|id| id.parse::<i32>()) { + None => { + // No id specified, show the current user's profile + view! { <OwnProfile logged_in_user /> }.into_view() + }, + Some(Ok(id)) => { + // Id specified, get the user and show their profile + view! { <UserIdProfile id /> }.into_view() + }, + Some(Err(e)) => { + // Invalid id, return an error + view! { + <Error<String> + title="Invalid User ID" + error=e.to_string() + /> + }.into_view() + } + } + })} + </div> + } +} + +/// Show the logged in user's profile +#[component] +fn OwnProfile(logged_in_user: LoggedInUserResource) -> impl IntoView { + view! { + <Transition + fallback=move || view! { <LoadingPage /> } + > + {move || logged_in_user.get().map(|user| { + match user { + Some(user) => { + let user_id = user.id.unwrap(); + view! { + <UserProfile user /> + <TopSongs user_id={user_id} /> + <RecentSongs user_id={user_id} /> + <TopArtists user_id={user_id} /> + }.into_view() + }, + None => view! { + <Error<String> + title="Not Logged In" + message="You must be logged in to view your profile" + /> + }.into_view(), + } + })} + </Transition> + } +} + +/// Show a user's profile by ID +#[component] +fn UserIdProfile(#[prop(into)] id: MaybeSignal<i32>) -> 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!{ + <Transition + fallback=move || view! { <LoadingPage /> } + > + {move || user_info.get().map(|user| { + match user { + Ok(Some(user)) => { + show_details.set(true); + + view! { <UserProfile user /> }.into_view() + }, + Ok(None) => { + show_details.set(false); + + view! { + <Error<String> + title="User Not Found" + message=format!("User with ID {} not found", id.get()) + /> + }.into_view() + }, + Err(error) => { + show_details.set(false); + + view! { + <ServerError<NoCustomError> + title="Error Getting User" + error + /> + }.into_view() + } + } + })} + </Transition> + <div hidden={move || !show_details.get()}> + <TopSongs user_id={id} /> + <RecentSongs user_id={id} /> + <TopArtists user_id={id} /> + </div> + } +} + +/// 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! { + <div class="profile-header"> + <object class="profile-image" data={profile_image_path.clone()} type="image/webp"> + <Icon class="profile-image" icon=icondata::CgProfile width="75" height="75"/> + </object> + <h1>{user.username}</h1> + </div> + <div class="profile-details"> + <p> + {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::<OffsetDateTime>::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 { + "" + } + } + </p> + </div> + } +} + +/// Show a list of top songs for a user +#[component] +fn TopSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> 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::<Vec<_>>() + }) + }); + + view! { + <h2>{format!("Top Songs {}", HISTORY_MESSAGE)}</h2> + <Transition + fallback=move || view! { <Loading /> } + > + <ErrorBoundary + fallback=|errors| view! { + {move || errors.get() + .into_iter() + .map(|(_, e)| view! { <p>{e.to_string()}</p>}) + .collect_view() + } + } + > + {move || + top_songs.get().map(|top_songs| { + top_songs.map(|top_songs| { + view! { + <SongListExtra songs={top_songs.into()} /> + } + }) + }) + } + </ErrorBoundary> + </Transition> + } +} + +/// Show a list of recently played songs for a user +#[component] +fn RecentSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> 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::<Vec<_>>() + }) + }); + + view! { + <h2>"Recently Played"</h2> + <Transition + fallback=move || view! { <Loading /> } + > + <ErrorBoundary + fallback=|errors| view! { + {move || errors.get() + .into_iter() + .map(|(_, e)| view! { <p>{e.to_string()}</p>}) + .collect_view() + } + } + > + {move || + recent_songs.get().map(|recent_songs| { + recent_songs.map(|recent_songs| { + view! { + <SongList songs={recent_songs.into()} /> + } + }) + }) + } + </ErrorBoundary> + </Transition> + } +} + +/// Show a list of top artists for a user +#[component] +fn TopArtists(#[prop(into)] user_id: MaybeSignal<i32>) -> 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::<Vec<_>>() + }) + }); + + view! { + <Transition + fallback=move || view! { + <h2>{format!("Top Artists {}", HISTORY_MESSAGE)}</h2> + <Loading /> + } + > + <ErrorBoundary + fallback=|errors| view! { + <h2>{format!("Top Artists {}", HISTORY_MESSAGE)}</h2> + {move || errors.get() + .into_iter() + .map(|(_, e)| view! { <p>{e.to_string()}</p>}) + .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<dyn DashboardTile> + }).collect::<Vec<_>>(); + + DashboardRow::new(format!("Top Artists {}", HISTORY_MESSAGE), tiles) + }) + }) + } + </ErrorBoundary> + </Transition> + } +} 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 <ethan@girouard.com> Date: Mon, 4 Nov 2024 17:16:09 -0500 Subject: [PATCH 46/52] 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; From 6dcbba258896ee5c18e3dfd8764572d7fe8b3657 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Mon, 4 Nov 2024 18:23:25 -0500 Subject: [PATCH 47/52] Switch to chrono instead of time --- Cargo.lock | 11 +++++++---- Cargo.toml | 5 +++-- src/albumdata.rs | 4 ++-- src/api/history.rs | 4 ++-- src/api/profile.rs | 12 ++++++------ src/models.rs | 15 +++++++-------- src/pages/profile.rs | 34 ++++++++++------------------------ src/songdata.rs | 4 ++-- src/upload.rs | 10 ++++------ 9 files changed, 43 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44b8a0a..76948b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,13 +378,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-targets 0.52.4", ] @@ -690,11 +693,11 @@ checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559" dependencies = [ "bitflags 2.5.0", "byteorder", + "chrono", "diesel_derives", "itoa", "pq-sys", "r2d2", - "time", ] [[package]] @@ -1834,6 +1837,7 @@ dependencies = [ "axum", "axum-login", "cfg-if", + "chrono", "console_error_panic_hook", "diesel", "diesel_migrations", @@ -1857,7 +1861,6 @@ dependencies = [ "server_fn", "symphonia", "thiserror", - "time", "tokio", "tower 0.5.1", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index 2bdcda4..88c540a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,10 @@ wasm-bindgen = { version = "=0.2.93", default-features = false, optional = true leptos_icons = { version = "0.3.0" } icondata = { version = "0.3.0" } dotenv = { version = "0.15.0", optional = true } -diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], default-features = false, optional = true } +diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true } lazy_static = { version = "1.4.0", optional = true } serde = { version = "1.0.195", features = ["derive"], default-features = false } openssl = { version = "0.10.63", optional = true } -time = { version = "0.3.34", features = ["serde"], default-features = false } 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"] } @@ -42,6 +41,7 @@ flexi_logger = { version = "0.28.0", optional = true, default-features = false } web-sys = "0.3.69" leptos-use = "0.13.5" image-convert = { version = "0.18.0", optional = true, default-features = false } +chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] } [features] hydrate = [ @@ -50,6 +50,7 @@ hydrate = [ "leptos_router/hydrate", "console_error_panic_hook", "wasm-bindgen", + "chrono/wasmbind", ] ssr = [ "dep:leptos_axum", diff --git a/src/albumdata.rs b/src/albumdata.rs index e42baea..2b86b25 100644 --- a/src/albumdata.rs +++ b/src/albumdata.rs @@ -1,7 +1,7 @@ use crate::models::Artist; use crate::components::dashboard_tile::DashboardTile; -use time::Date; +use chrono::NaiveDate; /// Holds information about an album /// @@ -14,7 +14,7 @@ pub struct AlbumData { /// Album artists pub artists: Vec<Artist>, /// Album release date - pub release_date: Option<Date>, + pub release_date: Option<NaiveDate>, /// Path to album image, relative to the root of the web server. /// For example, `"/assets/images/Album.jpg"` pub image_path: String, diff --git a/src/api/history.rs b/src/api/history.rs index 5f6cabb..697b255 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -1,4 +1,4 @@ -use std::time::SystemTime; +use chrono::NaiveDateTime; use leptos::*; use crate::models::HistoryEntry; use crate::models::Song; @@ -25,7 +25,7 @@ pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, Server /// Get the listen dates and songs of the current user. #[server(endpoint = "history/get_songs")] -pub async fn get_history_songs(limit: Option<i64>) -> Result<Vec<(SystemTime, Song)>, ServerFnError> { +pub async fn get_history_songs(limit: Option<i64>) -> Result<Vec<(NaiveDateTime, Song)>, ServerFnError> { let user = get_user().await?; let db_con = &mut get_db_conn(); let songs = user.get_history_songs(limit, db_con) diff --git a/src/api/profile.rs b/src/api/profile.rs index c8c8716..f994b49 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -6,7 +6,7 @@ use cfg_if::cfg_if; use crate::songdata::SongData; use crate::artistdata::ArtistData; -use std::time::SystemTime; +use chrono::NaiveDateTime; cfg_if! { if #[cfg(feature = "ssr")] { @@ -67,7 +67,7 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> { /// 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<i64>) -> Result<Vec<(SystemTime, SongData)>, ServerFnError> { +pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(NaiveDateTime, SongData)>, ServerFnError> { let mut db_con = get_db_conn(); // Get the ids of the most recent songs listened to @@ -108,7 +108,7 @@ pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(S .load(&mut db_con)?; // Process the history data into a map of song ids to song data - let mut history_songs: HashMap<i32, (SystemTime, SongData)> = HashMap::with_capacity(history.len()); + let mut history_songs: HashMap<i32, (NaiveDateTime, SongData)> = HashMap::with_capacity(history.len()); for (history, song, album, artist, like, dislike) in history { let song_id = history.song_id; @@ -148,7 +148,7 @@ pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(S } // Sort the songs by date - let mut history_songs: Vec<(SystemTime, SongData)> = history_songs.into_values().collect(); + let mut history_songs: Vec<(NaiveDateTime, SongData)> = history_songs.into_values().collect(); history_songs.sort_by(|a, b| b.0.cmp(&a.0)); Ok(history_songs) } @@ -158,7 +158,7 @@ pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(S /// 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<i64>) +pub async fn top_songs(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>) -> Result<Vec<(i64, SongData)>, ServerFnError> { let mut db_con = get_db_conn(); @@ -259,7 +259,7 @@ pub async fn top_songs(for_user_id: i32, start_date: SystemTime, end_date: Syste /// 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<i64>) +pub async fn top_artists(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>) -> Result<Vec<(i64, ArtistData)>, ServerFnError> { let mut db_con = get_db_conn(); diff --git a/src/models.rs b/src/models.rs index b96c8d2..4e2ca03 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,5 +1,4 @@ -use std::time::SystemTime; -use time::Date; +use chrono::{NaiveDate, NaiveDateTime}; use serde::{Deserialize, Serialize}; use cfg_if::cfg_if; @@ -39,8 +38,8 @@ pub struct User { #[cfg_attr(feature = "ssr", diesel(deserialize_as = String))] pub password: Option<String>, /// The time the user was created - #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] - pub created_at: Option<SystemTime>, + #[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))] + pub created_at: Option<NaiveDateTime>, /// Whether the user is an admin pub admin: bool, } @@ -103,7 +102,7 @@ impl User { /// #[cfg(feature = "ssr")] pub fn get_history_songs(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) -> - Result<Vec<(SystemTime, Song)>, Box<dyn Error>> { + Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> { use crate::schema::songs::dsl::*; use crate::schema::song_history::dsl::*; @@ -467,7 +466,7 @@ pub struct Album { /// The album's title pub title: String, /// The album's release date - pub release_date: Option<Date>, + pub release_date: Option<NaiveDate>, /// The path to the album's image file pub image_path: Option<String>, } @@ -546,7 +545,7 @@ pub struct Song { /// The duration of the song in seconds pub duration: i32, /// The song's release date - pub release_date: Option<Date>, + pub release_date: Option<NaiveDate>, /// The path to the song's audio file pub storage_path: String, /// The path to the song's image file @@ -622,7 +621,7 @@ pub struct HistoryEntry { /// The id of the user who listened to the song pub user_id: i32, /// The date the song was listened to - pub date: SystemTime, + pub date: NaiveDateTime, /// The id of the song that was listened to pub song_id: i32, } diff --git a/src/pages/profile.rs b/src/pages/profile.rs index 8bec37a..744229f 100644 --- a/src/pages/profile.rs +++ b/src/pages/profile.rs @@ -1,5 +1,4 @@ use leptos::*; -use leptos::logging::*; use leptos_router::use_params_map; use leptos_icons::*; use server_fn::error::NoCustomError; @@ -17,7 +16,7 @@ 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_SECS: i64 = 60 * 60 * 24 * 30; const HISTORY_MESSAGE: &str = "Last Month"; /// How many top songs to show @@ -160,19 +159,7 @@ fn UserProfile(user: User) -> impl IntoView { {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::<OffsetDateTime>::into(created_at).format(format); - - match date_time { - Ok(date_time) => { - format!(" • Joined {}", date_time) - }, - Err(e) => { - error!("Error formatting date: {}", e); - String::new() - } - } + format!(" • Joined {}", created_at.format("%B %Y")) }) } { @@ -191,11 +178,10 @@ fn UserProfile(user: User) -> impl IntoView { #[component] fn TopSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> 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; + use chrono::{Local, Duration}; + let now = Local::now(); + let start = now - Duration::seconds(HISTORY_SECS); + let top_songs = top_songs(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_SONGS_COUNT)).await; top_songs.map(|top_songs| { top_songs.into_iter().map(|(plays, song)| { @@ -283,11 +269,11 @@ fn RecentSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView { #[component] fn TopArtists(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView { let top_artists = create_resource(move || user_id.get(), |user_id| async move { - use std::time::{SystemTime, Duration}; + use chrono::{Local, 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; + let now = Local::now(); + let start = now - Duration::seconds(HISTORY_SECS); + let top_artists = top_artists(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_ARTISTS_COUNT)).await; top_artists.map(|top_artists| { top_artists.into_iter().map(|(_plays, artist)| { diff --git a/src/songdata.rs b/src/songdata.rs index bbbb64b..a70707f 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -2,7 +2,7 @@ use crate::models::{Album, Artist, Song}; use crate::components::dashboard_tile::DashboardTile; use serde::{Serialize, Deserialize}; -use time::Date; +use chrono::NaiveDate; /// Holds information about a song /// @@ -22,7 +22,7 @@ pub struct SongData { /// The duration of the song in seconds pub duration: i32, /// The song's release date - pub release_date: Option<Date>, + pub release_date: Option<NaiveDate>, /// Path to song file, relative to the root of the web server. /// For example, `"/assets/audio/Song.mp3"` pub song_path: String, diff --git a/src/upload.rs b/src/upload.rs index ff7d30d..083b0d2 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -10,7 +10,7 @@ cfg_if! { use diesel::prelude::*; use log::*; use server_fn::error::NoCustomError; - use time::Date; + use chrono::NaiveDate; } } @@ -124,15 +124,14 @@ async fn validate_track_number(track_number: Field<'static>) -> Result<Option<i3 /// Validate the release date in a multipart field /// Expects a field with a release date, and ensures it is a valid date in the format [year]-[month]-[day] #[cfg(feature = "ssr")] -async fn validate_release_date(release_date: Field<'static>) -> Result<Option<Date>, ServerFnError> { +async fn validate_release_date(release_date: Field<'static>) -> Result<Option<NaiveDate>, ServerFnError> { match release_date.text().await { Ok(release_date) => { if release_date.trim().is_empty() { return Ok(None); } - let date_format = time::macros::format_description!("[year]-[month]-[day]"); - let release_date = Date::parse(&release_date.trim(), date_format); + let release_date = NaiveDate::parse_from_str(&release_date.trim(), "%Y-%m-%d"); match release_date { Ok(release_date) => Ok(Some(release_date)), @@ -181,8 +180,7 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> { ServerError("Title field required and must precede file field".to_string()))?; let clean_title = title.replace(" ", "_").replace("/", "_"); - let date_format = time::macros::format_description!("[year]-[month]-[day]_[hour]:[minute]:[second]"); - let date_str = time::OffsetDateTime::now_utc().format(date_format).unwrap_or_default(); + let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string(); let upload_path = format!("assets/audio/upload-{}_{}.mp3", date_str, clean_title); file_name = Some(format!("upload-{}_{}.mp3", date_str, clean_title)); From 7f298f75ce908d20c628c62305a0fb55b3045cb0 Mon Sep 17 00:00:00 2001 From: Carter Bertolini <carterbertolini@gmail.com> Date: Tue, 12 Nov 2024 17:09:08 -0500 Subject: [PATCH 48/52] Change Playlist Model to use chronos time --- src/models.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/models.rs b/src/models.rs index 3119eec..fd3e217 100644 --- a/src/models.rs +++ b/src/models.rs @@ -636,11 +636,11 @@ pub struct Playlist { #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] pub id: Option<i32>, /// The time the playlist was created - #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] - pub created_at: Option<SystemTime>, + #[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))] + pub created_at: Option<NaiveDateTime>, /// The time the playlist was last updated - #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] - pub updated_at: Option<SystemTime>, + #[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))] + pub updated_at: Option<NaiveDateTime>, /// The id of the user who owns the playlist pub owner_id: i32, /// The name of the playlist From 41327c8ed631d6813d32a8bbdb97c0b9f8f9e42c Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 15 Nov 2024 13:23:20 -0500 Subject: [PATCH 49/52] Update wasm-bindgen to 0.2.95 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76948b4..c6e155b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,9 +1566,9 @@ dependencies = [ [[package]] name = "leptos-use" -version = "0.13.5" +version = "0.13.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb" +checksum = "32d4708472867704085a2813c47cada122a6e8c3b90ccff764862c0b351bfb96" dependencies = [ "cfg-if", "codee", @@ -3444,9 +3444,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -3455,9 +3455,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -3482,9 +3482,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3492,9 +3492,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -3505,9 +3505,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-streams" diff --git a/Cargo.toml b/Cargo.toml index 88c540a..b4be217 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] } leptos_meta = { version = "0.6", features = ["nightly"] } leptos_axum = { version = "0.6", optional = true } leptos_router = { version = "0.6", features = ["nightly"] } -wasm-bindgen = { version = "=0.2.93", default-features = false, optional = true } +wasm-bindgen = { version = "=0.2.95", default-features = false, optional = true } leptos_icons = { version = "0.3.0" } icondata = { version = "0.3.0" } dotenv = { version = "0.15.0", optional = true } From fe21785f2d0bbe17707ff6b7e8e334ecefdce3a6 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 15 Nov 2024 15:33:05 -0500 Subject: [PATCH 50/52] Implement SongList like/dislike functionality --- src/components/song_list.rs | 47 ++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/components/song_list.rs b/src/components/song_list.rs index e0c46d7..442b9d5 100644 --- a/src/components/song_list.rs +++ b/src/components/song_list.rs @@ -1,6 +1,8 @@ use leptos::*; +use leptos::logging::*; use leptos_icons::*; +use crate::api::songs::*; use crate::songdata::SongData; use crate::models::{Album, Artist}; @@ -70,7 +72,7 @@ pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: O <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 class="song-like-dislike"><SongLikeDislike song_id=song.id liked disliked/></td> <td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td> {extra.map(|extra| view! { <td class="song-list-spacer"></td> @@ -135,7 +137,12 @@ fn SongAlbum(album: Option<Album>) -> impl IntoView { /// 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 { +fn SongLikeDislike( + #[prop(into)] + song_id: MaybeSignal<i32>, + liked: RwSignal<bool>, + disliked: RwSignal<bool>) -> impl IntoView +{ let like_icon = Signal::derive(move || { if liked.get() { icondata::TbThumbUpFilled @@ -168,14 +175,48 @@ fn SongLikeDislike(liked: RwSignal<bool>, disliked: RwSignal<bool>) -> impl Into } }); + // If an error occurs, check the like/dislike status again to ensure consistency + let check_like_dislike = move || { + spawn_local(async move { + match get_like_dislike_song(song_id.get_untracked()).await { + Ok((like, dislike)) => { + liked.set(like); + disliked.set(dislike); + }, + Err(_) => {} + } + }); + }; + let toggle_like = move |_| { - liked.set(!liked.get_untracked()); + let new_liked = !liked.get_untracked(); + liked.set(new_liked); disliked.set(disliked.get_untracked() && !liked.get_untracked()); + + spawn_local(async move { + match set_like_song(song_id.get_untracked(), new_liked).await { + Ok(_) => {}, + Err(e) => { + error!("Error setting like: {}", e); + check_like_dislike(); + } + } + }); }; let toggle_dislike = move |_| { disliked.set(!disliked.get_untracked()); liked.set(liked.get_untracked() && !disliked.get_untracked()); + + spawn_local(async move { + match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await { + Ok(_) => {}, + Err(e) => { + error!("Error setting dislike: {}", e); + check_like_dislike(); + } + } + }); }; view! { From d42737f856e6516c198aa7787cf9ea76fee71ee9 Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 15 Nov 2024 18:35:06 -0500 Subject: [PATCH 51/52] Create GlobalState --- src/util/mod.rs | 2 ++ src/util/state.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/util/state.rs diff --git a/src/util/mod.rs b/src/util/mod.rs index cf7196e..d7ad020 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -5,3 +5,5 @@ cfg_if! { pub mod audio; } } + +pub mod state; diff --git a/src/util/state.rs b/src/util/state.rs new file mode 100644 index 0000000..b2fa59c --- /dev/null +++ b/src/util/state.rs @@ -0,0 +1,49 @@ +use leptos::*; +use leptos::logging::*; + +use crate::playstatus::PlayStatus; +use crate::models::User; +use crate::auth::get_logged_in_user; + +/// Global front-end state +/// Contains anything frequently needed across multiple components +/// Behaves like a singleton, in that provide/expect_context will +/// always return the same instance +#[derive(Clone)] +pub struct GlobalState { + /// A resource that fetches the logged in user + /// This will not automatically refetch, so any login/logout related code + /// should call `refetch` on this resource + pub logged_in_user: Resource<(), Option<User>>, + + /// The current play status + pub play_status: RwSignal<PlayStatus>, +} + +impl GlobalState { + pub fn new() -> Self { + let play_status = create_rw_signal(PlayStatus::default()); + + let logged_in_user = create_resource(|| (), |_| async { + get_logged_in_user().await + .inspect_err(|e| { + error!("Error getting logged in user: {:?}", e); + }) + .ok() + .flatten() + }); + + Self { + logged_in_user, + play_status, + } + } + + pub fn logged_in_user() -> Resource<(), Option<User>> { + expect_context::<Self>().logged_in_user + } + + pub fn play_status() -> RwSignal<PlayStatus> { + expect_context::<Self>().play_status + } +} From f0f34d4abe093dc800e3ddacde3326027ce3d13f Mon Sep 17 00:00:00 2001 From: Ethan Girouard <ethan@girouard.com> Date: Fri, 15 Nov 2024 18:49:19 -0500 Subject: [PATCH 52/52] Use GlobalState instead of passing play_status/logged_in_user everywhere --- src/app.rs | 41 ++++++------------ src/pages/login.rs | 6 ++- src/pages/profile.rs | 10 ++--- src/pages/signup.rs | 6 ++- src/playbar.rs | 100 +++++++++++++++++++++++-------------------- src/queue.rs | 11 ++--- 6 files changed, 85 insertions(+), 89 deletions(-) diff --git a/src/app.rs b/src/app.rs index 15d877f..2058312 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,40 +1,23 @@ use crate::playbar::PlayBar; use crate::playbar::CustomTitle; -use crate::playstatus::PlayStatus; use crate::queue::Queue; use leptos::*; -use leptos::logging::*; 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; - -pub type LoggedInUserResource = Resource<(), Option<User>>; +use crate::util::state::GlobalState; #[component] pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); - let play_status = PlayStatus::default(); - let play_status = create_rw_signal(play_status); - let upload_open = create_rw_signal(false); + provide_context(GlobalState::new()); - // A resource that fetches the logged in user - // This will not automatically refetch, so any login/logout related code - // should call `refetch` on this resource - let logged_in_user: LoggedInUserResource = create_resource(|| (), |_| async { - get_logged_in_user().await - .inspect_err(|e| { - error!("Error getting logged in user: {:?}", e); - }) - .ok() - .flatten() - }); + let upload_open = create_rw_signal(false); view! { // injects a stylesheet into the document <head> @@ -42,7 +25,7 @@ pub fn App() -> impl IntoView { <Stylesheet id="leptos" href="/pkg/libretunes.css"/> // sets the document title - <CustomTitle play_status=play_status/> + <CustomTitle /> // content for this welcome page <Router fallback=|| { @@ -55,15 +38,15 @@ pub fn App() -> impl IntoView { }> <main> <Routes> - <Route path="" view=move || view! { <HomePage play_status=play_status upload_open=upload_open/> }> + <Route path="" view=move || view! { <HomePage upload_open=upload_open/> }> <Route path="" view=Dashboard /> <Route path="dashboard" view=Dashboard /> <Route path="search" view=Search /> - <Route path="user/:id" view=move || view!{ <Profile logged_in_user /> } /> - <Route path="user" view=move || view!{ <Profile logged_in_user /> } /> + <Route path="user/:id" view=Profile /> + <Route path="user" view=Profile /> </Route> - <Route path="/login" view=move || view!{ <Login user=logged_in_user /> } /> - <Route path="/signup" view=move || view!{ <Signup user=logged_in_user /> } /> + <Route path="/login" view=Login /> + <Route path="/signup" view=Signup /> </Routes> </main> </Router> @@ -78,7 +61,7 @@ use crate::components::upload::*; /// Renders the home page of your application. #[component] -fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> impl IntoView { +fn HomePage(upload_open: RwSignal<bool>) -> impl IntoView { view! { <div class="home-container"> <Upload open=upload_open/> @@ -86,8 +69,8 @@ fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> i // This <Outlet /> will render the child route components <Outlet /> <Personal /> - <PlayBar status=play_status/> - <Queue status=play_status/> + <PlayBar /> + <Queue /> </div> } } diff --git a/src/pages/login.rs b/src/pages/login.rs index 585f2f0..7b9bffa 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -1,12 +1,12 @@ use crate::auth::login; +use crate::util::state::GlobalState; use leptos::leptos_dom::*; use leptos::*; use leptos_icons::*; use crate::users::UserCredentials; -use crate::app::LoggedInUserResource; #[component] -pub fn Login(user: LoggedInUserResource) -> impl IntoView { +pub fn Login() -> impl IntoView { let (username_or_email, set_username_or_email) = create_signal("".to_string()); let (password, set_password) = create_signal("".to_string()); @@ -28,6 +28,8 @@ pub fn Login(user: LoggedInUserResource) -> impl IntoView { username_or_email: username_or_email1, password: password1 }; + + let user = GlobalState::logged_in_user(); let login_result = login(user_credentials).await; if let Err(err) = login_result { diff --git a/src/pages/profile.rs b/src/pages/profile.rs index 744229f..2367470 100644 --- a/src/pages/profile.rs +++ b/src/pages/profile.rs @@ -11,9 +11,9 @@ use crate::components::error::*; use crate::api::profile::*; -use crate::app::LoggedInUserResource; use crate::models::User; use crate::users::get_user_by_id; +use crate::util::state::GlobalState; /// Duration in seconds backwards from now to aggregate history data for const HISTORY_SECS: i64 = 60 * 60 * 24 * 30; @@ -29,7 +29,7 @@ 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 { +pub fn Profile() -> impl IntoView { let params = use_params_map(); view! { @@ -38,7 +38,7 @@ pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView { match params.get("id").map(|id| id.parse::<i32>()) { None => { // No id specified, show the current user's profile - view! { <OwnProfile logged_in_user /> }.into_view() + view! { <OwnProfile /> }.into_view() }, Some(Ok(id)) => { // Id specified, get the user and show their profile @@ -61,12 +61,12 @@ pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView { /// Show the logged in user's profile #[component] -fn OwnProfile(logged_in_user: LoggedInUserResource) -> impl IntoView { +fn OwnProfile() -> impl IntoView { view! { <Transition fallback=move || view! { <LoadingPage /> } > - {move || logged_in_user.get().map(|user| { + {move || GlobalState::logged_in_user().get().map(|user| { match user { Some(user) => { let user_id = user.id.unwrap(); diff --git a/src/pages/signup.rs b/src/pages/signup.rs index 8e9a0ac..69fe77d 100644 --- a/src/pages/signup.rs +++ b/src/pages/signup.rs @@ -1,12 +1,12 @@ use crate::auth::signup; use crate::models::User; +use crate::util::state::GlobalState; use leptos::leptos_dom::*; use leptos::*; use leptos_icons::*; -use crate::app::LoggedInUserResource; #[component] -pub fn Signup(user: LoggedInUserResource) -> impl IntoView { +pub fn Signup() -> impl IntoView { let (username, set_username) = create_signal("".to_string()); let (email, set_email) = create_signal("".to_string()); let (password, set_password) = create_signal("".to_string()); @@ -30,6 +30,8 @@ pub fn Signup(user: LoggedInUserResource) -> impl IntoView { }; log!("new user: {:?}", new_user); + let user = GlobalState::logged_in_user(); + spawn_local(async move { if let Err(err) = signup(new_user.clone()).await { // Handle the error here, e.g., log it or display to the user diff --git a/src/playbar.rs b/src/playbar.rs index d113b7a..e581101 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -1,7 +1,7 @@ use crate::models::Artist; -use crate::playstatus::PlayStatus; use crate::songdata::SongData; use crate::api::songs; +use crate::util::state::GlobalState; use leptos::ev::MouseEvent; use leptos::html::{Audio, Div}; use leptos::leptos_dom::*; @@ -40,8 +40,8 @@ const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64; /// * `None` if the audio element is not available /// * `Some((current_time, duration))` if the audio element is available /// -pub fn get_song_time_duration(status: impl SignalWithUntracked<Value = PlayStatus>) -> Option<(f64, f64)> { - status.with_untracked(|status| { +pub fn get_song_time_duration() -> Option<(f64, f64)> { + GlobalState::play_status().with_untracked(|status| { if let Some(audio) = status.get_audio() { Some((audio.current_time(), audio.duration())) } else { @@ -61,13 +61,13 @@ pub fn get_song_time_duration(status: impl SignalWithUntracked<Value = PlayStatu /// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `time` - The time to skip to, in seconds /// -pub fn skip_to(status: impl SignalUpdate<Value = PlayStatus>, time: f64) { +pub fn skip_to(time: f64) { if time.is_infinite() || time.is_nan() { error!("Unable to skip to non-finite time: {}", time); return } - status.update(|status| { + GlobalState::play_status().update(|status| { if let Some(audio) = status.get_audio() { audio.set_current_time(time); log!("Player skipped to time: {}", time); @@ -85,8 +85,8 @@ pub fn skip_to(status: impl SignalUpdate<Value = PlayStatus>, time: f64) { /// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `play` - `true` to play the song, `false` to pause it /// -pub fn set_playing(status: impl SignalUpdate<Value = PlayStatus>, play: bool) { - status.update(|status| { +pub fn set_playing(play: bool) { + GlobalState::play_status().update(|status| { if let Some(audio) = status.get_audio() { if play { if let Err(e) = audio.play() { @@ -109,8 +109,8 @@ pub fn set_playing(status: impl SignalUpdate<Value = PlayStatus>, play: bool) { }); } -fn toggle_queue(status: impl SignalUpdate<Value = PlayStatus>) { - status.update(|status| { +fn toggle_queue() { + GlobalState::play_status().update(|status| { status.queue_open = !status.queue_open; }); @@ -126,8 +126,8 @@ fn toggle_queue(status: impl SignalUpdate<Value = PlayStatus>) { /// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `src` - The source to set the audio player to /// -fn set_play_src(status: impl SignalUpdate<Value = PlayStatus>, src: String) { - status.update(|status| { +fn set_play_src(src: String) { + GlobalState::play_status().update(|status| { if let Some(audio) = status.get_audio() { audio.set_src(&src); log!("Player set src to: {}", src); @@ -139,11 +139,13 @@ fn set_play_src(status: impl SignalUpdate<Value = PlayStatus>, src: String) { /// The play, pause, and skip buttons #[component] -fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView { +fn PlayControls() -> impl IntoView { + let status = GlobalState::play_status(); + // On click handlers for the skip and play/pause buttons let skip_back = move |_| { - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { // Skip to previous song if the current song is near the start // Also skip to the previous song if we're at the end of the current song // This is because after running out of songs in the queue, the current song will be at the end @@ -160,8 +162,8 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView { // Push the popped song to the front of the queue, and play it let next_src = last_played_song.song_path.clone(); status.update(|status| status.queue.push_front(last_played_song)); - set_play_src(status, next_src); - set_playing(status, true); + set_play_src(next_src); + set_playing(true); } else { warn!("Unable to skip back: No previous song"); } @@ -170,14 +172,14 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView { // Default to skipping to start of current song, and playing log!("Skipping to start of current song"); - skip_to(status, 0.0); - set_playing(status, true); + skip_to(0.0); + set_playing(true); }; let skip_forward = move |_| { - if let Some(duration) = get_song_time_duration(status) { - skip_to(status, duration.1); - set_playing(status, true); + if let Some(duration) = get_song_time_duration() { + skip_to(duration.1); + set_playing(true); } else { error!("Unable to skip forward: Unable to get current duration"); } @@ -185,7 +187,7 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView { let toggle_play = move |_| { let playing = status.with_untracked(|status| { status.playing }); - set_playing(status, !playing); + set_playing(!playing); }; // We use this to prevent the buttons from being focused when clicked @@ -248,7 +250,9 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) -> /// The name, artist, and album of the current song #[component] -fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView { +fn MediaInfo() -> impl IntoView { + let status = GlobalState::play_status(); + let name = Signal::derive(move || { status.with(|status| { status.queue.front().map_or("No media playing".into(), |song| song.title.clone()) @@ -287,7 +291,9 @@ fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView { /// The like and dislike buttons #[component] -fn LikeDislike(status: RwSignal<PlayStatus>) -> impl IntoView { +fn LikeDislike() -> impl IntoView { + let status = GlobalState::play_status(); + let like_icon = Signal::derive(move || { status.with(|status| { match status.queue.front() { @@ -400,7 +406,7 @@ fn LikeDislike(status: RwSignal<PlayStatus>) -> impl IntoView { /// The play progress bar, and click handler for skipping to a certain time in the song #[component] -fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> impl IntoView { +fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView { // Keep a reference to the progress bar div so we can get its width and calculate the time to skip to let progress_bar_ref = create_node_ref::<Div>(); @@ -412,10 +418,10 @@ fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> im let width = progress_bar.offset_width() as f64; let percentage = x_click_pos / width * 100.0; - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { let time = duration.1 * percentage / 100.0; - skip_to(status, time); - set_playing(status, true); + skip_to(time); + set_playing(true); } else { error!("Unable to skip to time: Unable to get current duration"); } @@ -438,11 +444,11 @@ fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> im } #[component] -fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView { - +fn QueueToggle() -> impl IntoView { let update_queue = move |_| { - toggle_queue(status); - log!("queue button pressed, queue status: {:?}", status.with_untracked(|status| status.queue_open)); + toggle_queue(); + log!("queue button pressed, queue status: {:?}", + GlobalState::play_status().with_untracked(|status| status.queue_open)); }; // We use this to prevent the buttons from being focused when clicked @@ -463,9 +469,9 @@ 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 { +pub fn CustomTitle() -> impl IntoView { let title = create_memo(move |_| { - play_status.with(|play_status| { + GlobalState::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") }) @@ -478,18 +484,20 @@ pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView { /// 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 { +pub fn PlayBar() -> impl IntoView { + let status = GlobalState::play_status(); + // Listen for key down events -- arrow keys don't seem to trigger key press events let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| { if e.key() == "ArrowRight" { e.prevent_default(); log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME); - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { let mut time = duration.0 + ARROW_KEY_SKIP_TIME; time = time.clamp(0.0, duration.1); - skip_to(status, time); - set_playing(status, true); + skip_to(time); + set_playing(true); } else { error!("Unable to skip forward: Unable to get current duration"); } @@ -498,11 +506,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { e.prevent_default(); log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME); - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { let mut time = duration.0 - ARROW_KEY_SKIP_TIME; time = time.clamp(0.0, duration.1); - skip_to(status, time); - set_playing(status, true); + skip_to(time); + set_playing(true); } else { error!("Unable to skip backward: Unable to get current duration"); } @@ -516,7 +524,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { log!("Space bar pressed, toggling play/pause"); let playing = status.with_untracked(|status| status.playing); - set_playing(status, !playing); + set_playing(!playing); } }); @@ -659,14 +667,14 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { <audio _ref=audio_ref on:play=on_play on:pause=on_pause on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" /> <div class="playbar"> - <ProgressBar percentage=percentage.into() status=status /> + <ProgressBar percentage=percentage.into() /> <div class="playbar-left-group"> - <MediaInfo status=status /> - <LikeDislike status=status /> + <MediaInfo /> + <LikeDislike /> </div> - <PlayControls status=status /> + <PlayControls /> <PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() /> - <QueueToggle status=status /> + <QueueToggle /> </div> } } diff --git a/src/queue.rs b/src/queue.rs index 8a819b9..2204809 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,6 +1,6 @@ use crate::models::Artist; -use crate::playstatus::PlayStatus; use crate::song::Song; +use crate::util::state::GlobalState; use leptos::ev::MouseEvent; use leptos::leptos_dom::*; use leptos::*; @@ -9,22 +9,23 @@ use leptos::ev::DragEvent; const RM_BTN_SIZE: &str = "2.5rem"; -fn remove_song_fn(index: usize, status: RwSignal<PlayStatus>) { +fn remove_song_fn(index: usize) { if index == 0 { log!("Error: Trying to remove currently playing song (index 0) from queue"); } else { log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history"); - status.update(|status| { + GlobalState::play_status().update(|status| { status.queue.remove(index); }); } } #[component] -pub fn Queue(status: RwSignal<PlayStatus>) -> impl IntoView { +pub fn Queue() -> impl IntoView { + let status = GlobalState::play_status(); let remove_song = move |index: usize| { - remove_song_fn(index, status); + remove_song_fn(index); log!("Removed song {}", index + 1); };