Merge branch '106-create-song-list-component' into 42-create-profile-page
This commit is contained in:
commit
4e4c94a189
79
Cargo.lock
generated
79
Cargo.lock
generated
@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "addr2line"
|
name = "addr2line"
|
||||||
@ -151,7 +151,7 @@ dependencies = [
|
|||||||
"axum-core",
|
"axum-core",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
@ -167,7 +167,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"sync_wrapper 1.0.0",
|
"sync_wrapper 1.0.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.4.13",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@ -181,7 +181,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"mime",
|
"mime",
|
||||||
@ -1017,13 +1017,14 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-net"
|
name = "gloo-net"
|
||||||
version = "0.5.0"
|
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 = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"gloo-utils",
|
"gloo-utils",
|
||||||
"http",
|
"http 0.2.12",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"serde",
|
"serde",
|
||||||
@ -1049,7 +1050,8 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-utils"
|
name = "gloo-utils"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
@ -1117,6 +1119,17 @@ dependencies = [
|
|||||||
"utf8-width",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -1135,7 +1148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
|
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1146,7 +1159,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
@ -1178,7 +1191,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
@ -1196,7 +1209,7 @@ checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@ -1826,7 +1839,7 @@ dependencies = [
|
|||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"flexi_logger",
|
"flexi_logger",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"icondata",
|
"icondata",
|
||||||
"image-convert",
|
"image-convert",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
@ -1846,7 +1859,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.1",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-sessions-redis-store",
|
"tower-sessions-redis-store",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@ -2023,7 +2036,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
@ -2670,7 +2683,7 @@ dependencies = [
|
|||||||
"dashmap",
|
"dashmap",
|
||||||
"futures",
|
"futures",
|
||||||
"gloo-net",
|
"gloo-net",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"inventory",
|
"inventory",
|
||||||
@ -2683,7 +2696,7 @@ dependencies = [
|
|||||||
"serde_qs",
|
"serde_qs",
|
||||||
"server_fn_macro_default",
|
"server_fn_macro_default",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tower",
|
"tower 0.4.13",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@ -3109,6 +3122,20 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "tower-cookies"
|
name = "tower-cookies"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@ -3119,7 +3146,7 @@ dependencies = [
|
|||||||
"axum-core",
|
"axum-core",
|
||||||
"cookie",
|
"cookie",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
@ -3128,14 +3155,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.5.2"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.5.0",
|
"bitflags 2.5.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"http-range-header",
|
"http-range-header",
|
||||||
@ -3153,15 +3180,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-layer"
|
name = "tower-layer"
|
||||||
version = "0.3.2"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-service"
|
name = "tower-service"
|
||||||
version = "0.3.2"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-sessions"
|
name = "tower-sessions"
|
||||||
@ -3170,7 +3197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b27326208b21807803c5f5aa1020d30ca0432b78cfe251b51a67a05e0baea102"
|
checksum = "b27326208b21807803c5f5aa1020d30ca0432b78cfe251b51a67a05e0baea102"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-cookies",
|
"tower-cookies",
|
||||||
@ -3191,7 +3218,7 @@ dependencies = [
|
|||||||
"axum-core",
|
"axum-core",
|
||||||
"base64",
|
"base64",
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http 1.1.0",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -28,8 +28,8 @@ diesel_migrations = { version = "2.1.0", optional = true }
|
|||||||
pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true }
|
pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true }
|
||||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread"] }
|
tokio = { version = "1", optional = true, features = ["rt-multi-thread"] }
|
||||||
axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true }
|
axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true }
|
||||||
tower = { version = "0.4.13", optional = true }
|
tower = { version = "0.5.1", optional = true, features = ["util"] }
|
||||||
tower-http = { version = "0.5", optional = true, features = ["fs"] }
|
tower-http = { version = "0.6.1", optional = true, features = ["fs"] }
|
||||||
thiserror = "1.0.57"
|
thiserror = "1.0.57"
|
||||||
tower-sessions-redis-store = { version = "0.11", optional = true }
|
tower-sessions-redis-store = { version = "0.11", optional = true }
|
||||||
async-trait = { version = "0.1.79", optional = true }
|
async-trait = { version = "0.1.79", optional = true }
|
||||||
@ -43,9 +43,6 @@ web-sys = "0.3.69"
|
|||||||
leptos-use = "0.13.5"
|
leptos-use = "0.13.5"
|
||||||
image-convert = { version = "0.18.0", optional = true, default-features = false }
|
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]
|
[features]
|
||||||
hydrate = [
|
hydrate = [
|
||||||
"leptos/hydrate",
|
"leptos/hydrate",
|
||||||
|
39
src/albumdata.rs
Normal file
39
src/albumdata.rs
Normal file
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
use crate::playbar::PlayBar;
|
use crate::playbar::PlayBar;
|
||||||
|
use crate::playbar::CustomTitle;
|
||||||
use crate::playstatus::PlayStatus;
|
use crate::playstatus::PlayStatus;
|
||||||
use crate::queue::Queue;
|
use crate::queue::Queue;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
@ -24,7 +25,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
|
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
|
||||||
|
|
||||||
// sets the document title
|
// sets the document title
|
||||||
<Title text="LibreTunes"/>
|
<CustomTitle play_status=play_status/>
|
||||||
|
|
||||||
// content for this welcome page
|
// content for this welcome page
|
||||||
<Router fallback=|| {
|
<Router fallback=|| {
|
||||||
|
32
src/artistdata.rs
Normal file
32
src/artistdata.rs
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
@ -2,4 +2,7 @@ pub mod sidebar;
|
|||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod personal;
|
pub mod personal;
|
||||||
|
pub mod dashboard_tile;
|
||||||
|
pub mod dashboard_row;
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
|
pub mod song_list;
|
||||||
|
118
src/components/dashboard_row.rs
Normal file
118
src/components/dashboard_row.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
use leptos::html::Ul;
|
||||||
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn};
|
||||||
|
use crate::components::dashboard_tile::DashboardTile;
|
||||||
|
use leptos_icons::*;
|
||||||
|
|
||||||
|
/// A row of dashboard tiles, with a title
|
||||||
|
pub struct DashboardRow {
|
||||||
|
pub title: String,
|
||||||
|
pub tiles: Vec<Box<dyn DashboardTile>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DashboardRow {
|
||||||
|
pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self {
|
||||||
|
Self {
|
||||||
|
title,
|
||||||
|
tiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoView for DashboardRow {
|
||||||
|
fn into_view(self) -> View {
|
||||||
|
let list_ref = create_node_ref::<Ul>();
|
||||||
|
|
||||||
|
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
|
||||||
|
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding
|
||||||
|
|
||||||
|
let scroll_left = move |_| {
|
||||||
|
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||||
|
let client_width = scroll_element.client_width() as f64;
|
||||||
|
let current_pos = scroll_element.scroll_left() as f64;
|
||||||
|
let desired_pos = current_pos - client_width;
|
||||||
|
|
||||||
|
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||||
|
let tile_width = first_tile.client_width() as f64;
|
||||||
|
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
|
||||||
|
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||||
|
} else {
|
||||||
|
warn!("Could not get first tile to scroll left");
|
||||||
|
// Fall back to scrolling by the client width if we can't get the tile width
|
||||||
|
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Could not get scroll element to scroll left");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scroll_right = move |_| {
|
||||||
|
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||||
|
let client_width = scroll_element.client_width() as f64;
|
||||||
|
let current_pos = scroll_element.scroll_left() as f64;
|
||||||
|
let desired_pos = current_pos + client_width;
|
||||||
|
|
||||||
|
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||||
|
let tile_width = first_tile.client_width() as f64;
|
||||||
|
let scroll_pos = desired_pos - (desired_pos % tile_width);
|
||||||
|
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||||
|
} else {
|
||||||
|
warn!("Could not get first tile to scroll right");
|
||||||
|
// Fall back to scrolling by the client width if we can't get the tile width
|
||||||
|
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Could not get scroll element to scroll right");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let UseElementSizeReturn { width: scroll_element_width, .. } = use_element_size(list_ref);
|
||||||
|
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
|
||||||
|
|
||||||
|
let scroll_right_hidden = Signal::derive(move || {
|
||||||
|
if let Some(scroll_element) = list_ref.get() {
|
||||||
|
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
|
||||||
|
"visibility: hidden"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let scroll_left_hidden = Signal::derive(move || {
|
||||||
|
if scroll_x.get() <= 0.0 {
|
||||||
|
"visibility: hidden"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="dashboard-tile-row">
|
||||||
|
<div class="dashboard-tile-row-title-row">
|
||||||
|
<h2>{self.title}</h2>
|
||||||
|
<div class="dashboard-tile-row-scroll-btn">
|
||||||
|
<button on:click=scroll_left tabindex=-1 style=scroll_left_hidden>
|
||||||
|
<Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronLeft />
|
||||||
|
</button>
|
||||||
|
<button on:click=scroll_right tabindex=-1 style=scroll_right_hidden>
|
||||||
|
<Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul _ref={list_ref}>
|
||||||
|
{self.tiles.into_iter().map(|tile_info| {
|
||||||
|
view! {
|
||||||
|
<li>
|
||||||
|
{ tile_info.into_view() }
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
}
|
||||||
|
}
|
27
src/components/dashboard_tile.rs
Normal file
27
src/components/dashboard_tile.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
pub trait DashboardTile {
|
||||||
|
fn image_path(&self) -> String;
|
||||||
|
fn title(&self) -> String;
|
||||||
|
fn link(&self) -> String;
|
||||||
|
fn description(&self) -> Option<String> { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoView for &dyn DashboardTile {
|
||||||
|
fn into_view(self) -> View {
|
||||||
|
let link = self.link();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="dashboard-tile">
|
||||||
|
<a href={link}>
|
||||||
|
<img src={self.image_path()} alt="dashboard-tile" />
|
||||||
|
<p class="dashboard-tile-title">{self.title()}</p>
|
||||||
|
<p class="dashboard-tile-description">
|
||||||
|
{self.description().unwrap_or_default()}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
}
|
||||||
|
}
|
157
src/components/song_list.rs
Normal file
157
src/components/song_list.rs
Normal file
@ -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>
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod songdata;
|
pub mod songdata;
|
||||||
|
pub mod albumdata;
|
||||||
|
pub mod artistdata;
|
||||||
pub mod playstatus;
|
pub mod playstatus;
|
||||||
pub mod playbar;
|
pub mod playbar;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
|
@ -5,6 +5,7 @@ use crate::api::songs;
|
|||||||
use leptos::ev::MouseEvent;
|
use leptos::ev::MouseEvent;
|
||||||
use leptos::html::{Audio, Div};
|
use leptos::html::{Audio, Div};
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos_meta::Title;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
use leptos_use::{utils::Pausable, use_interval_fn};
|
use leptos_use::{utils::Pausable, use_interval_fn};
|
||||||
@ -460,6 +461,21 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders the title of the page based on the currently playing song
|
||||||
|
#[component]
|
||||||
|
pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||||
|
let title = create_memo(move |_| {
|
||||||
|
play_status.with(|play_status| {
|
||||||
|
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| {
|
||||||
|
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
view! {
|
||||||
|
<Title text=title />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use crate::models::{Album, Artist, Song};
|
use crate::models::{Album, Artist, Song};
|
||||||
|
use crate::components::dashboard_tile::DashboardTile;
|
||||||
|
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use time::Date;
|
use time::Date;
|
||||||
@ -6,7 +7,7 @@ use time::Date;
|
|||||||
/// Holds information about a song
|
/// 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.
|
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct SongData {
|
pub struct SongData {
|
||||||
/// Song id
|
/// Song id
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
@ -62,3 +63,21 @@ impl TryInto<Song> for SongData {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DashboardTile for SongData {
|
||||||
|
fn image_path(&self) -> String {
|
||||||
|
self.image_path.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
self.title.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(&self) -> String {
|
||||||
|
format!("/song/{}", self.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> Option<String> {
|
||||||
|
Some(format!("Song • {}", Artist::display_list(&self.artists)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
45
style/dashboard_row.scss
Normal file
45
style/dashboard_row.scss
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
.dashboard-tile-row {
|
||||||
|
.dashboard-tile-row-title-row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.dashboard-tile-row-scroll-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.dashboard-tile-row-scroll {
|
||||||
|
color: $text-controls-color;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tile-row-scroll:hover {
|
||||||
|
color: $controls-hover-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tile-row-scroll:active {
|
||||||
|
color: $controls-click-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
margin-left: 40px;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
-webkit-mask-image: linear-gradient(90deg, #000000 95%, transparent);
|
||||||
|
mask-image: linear-gradient(90deg, #000000 95%, transparent);
|
||||||
|
}
|
||||||
|
}
|
28
style/dashboard_tile.scss
Normal file
28
style/dashboard_tile.scss
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
.dashboard-tile {
|
||||||
|
img {
|
||||||
|
width: $dashboard-tile-size;
|
||||||
|
height: $dashboard-tile-size;
|
||||||
|
border-radius: 7px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $text-controls-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.dashboard-tile-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.dashboard-tile-description {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
@ -8,8 +8,11 @@
|
|||||||
@import 'home.scss';
|
@import 'home.scss';
|
||||||
@import 'search.scss';
|
@import 'search.scss';
|
||||||
@import 'personal.scss';
|
@import 'personal.scss';
|
||||||
|
@import 'dashboard_tile.scss';
|
||||||
|
@import 'dashboard_row.scss';
|
||||||
@import 'upload.scss';
|
@import 'upload.scss';
|
||||||
@import 'error.scss';
|
@import 'error.scss';
|
||||||
|
@import 'song_list.scss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
124
style/song_list.scss
Normal file
124
style/song_list.scss
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -14,3 +14,5 @@ $queue-background-color: $play-bar-background-color;
|
|||||||
|
|
||||||
$auth-inputs: #796dd4;
|
$auth-inputs: #796dd4;
|
||||||
$auth-containers: white;
|
$auth-containers: white;
|
||||||
|
|
||||||
|
$dashboard-tile-size: 200px;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user