Compare commits
50 Commits
136-create
...
151-scroll
Author | SHA1 | Date | |
---|---|---|---|
d200ece6cc | |||
9b48fc0204 | |||
dfbb217988 | |||
954cc0edce | |||
e5ce0eab76 | |||
45eb7191f0 | |||
21a17a8eb5 | |||
fcd987d433 | |||
dd14aa0b4d | |||
3b6035dd71 | |||
22cee4a265 | |||
b25cb4549c | |||
b1a62ee284 | |||
0c1bab9fbb
|
|||
190df0edfd | |||
73b4b7674e | |||
3d1dc94b06 | |||
a3b3174306
|
|||
f633cffe69
|
|||
cf6f7b7db7
|
|||
8f01ff24d8 | |||
f0f34d4abe
|
|||
d42737f856
|
|||
47fc35814d | |||
fe21785f2d
|
|||
c6024a7457 | |||
41327c8ed6
|
|||
d09060959a | |||
d8fab0e068 | |||
7f298f75ce | |||
74044f2e17 | |||
568f6ada0e | |||
7a1ffaad47 | |||
f1affc66bc | |||
e60243e50c | |||
0335e3d255 | |||
81b1490cce | |||
ff24f68eed | |||
6aa933be09 | |||
fe1b76e6e4 | |||
acf15961cd | |||
df01bafbd1 | |||
2d7b91413b | |||
c2ebd8307f | |||
2be665c549 | |||
f104a14f98 | |||
f78066d7a8 | |||
071dcad0cc | |||
3149f65a97 | |||
ab50826d31 |
32
Cargo.lock
generated
32
Cargo.lock
generated
@ -755,10 +755,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenvy"
|
||||||
version = "0.15.0"
|
version = "0.15.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drain_filter_polyfill"
|
name = "drain_filter_polyfill"
|
||||||
@ -1566,9 +1566,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leptos-use"
|
name = "leptos-use"
|
||||||
version = "0.13.5"
|
version = "0.13.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb"
|
checksum = "32d4708472867704085a2813c47cada122a6e8c3b90ccff764862c0b351bfb96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"codee",
|
"codee",
|
||||||
@ -1841,7 +1841,7 @@ dependencies = [
|
|||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenvy",
|
||||||
"flexi_logger",
|
"flexi_logger",
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"icondata",
|
"icondata",
|
||||||
@ -3444,9 +3444,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
|
checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@ -3455,9 +3455,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-backend"
|
name = "wasm-bindgen-backend"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
|
checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"log",
|
"log",
|
||||||
@ -3482,9 +3482,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
|
checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@ -3492,9 +3492,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -3505,9 +3505,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
|
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
|
@ -15,10 +15,9 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] }
|
|||||||
leptos_meta = { version = "0.6", features = ["nightly"] }
|
leptos_meta = { version = "0.6", features = ["nightly"] }
|
||||||
leptos_axum = { version = "0.6", optional = true }
|
leptos_axum = { version = "0.6", optional = true }
|
||||||
leptos_router = { version = "0.6", features = ["nightly"] }
|
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" }
|
leptos_icons = { version = "0.3.0" }
|
||||||
icondata = { version = "0.3.0" }
|
icondata = { version = "0.3.0" }
|
||||||
dotenv = { version = "0.15.0", optional = true }
|
|
||||||
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], 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 }
|
lazy_static = { version = "1.4.0", optional = true }
|
||||||
serde = { version = "1.0.195", features = ["derive"], default-features = false }
|
serde = { version = "1.0.195", features = ["derive"], default-features = false }
|
||||||
@ -42,6 +41,7 @@ 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 }
|
||||||
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
|
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
|
||||||
|
dotenvy = { version = "0.15.7", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
hydrate = [
|
hydrate = [
|
||||||
@ -57,7 +57,7 @@ ssr = [
|
|||||||
"leptos/ssr",
|
"leptos/ssr",
|
||||||
"leptos_meta/ssr",
|
"leptos_meta/ssr",
|
||||||
"leptos_router/ssr",
|
"leptos_router/ssr",
|
||||||
"dotenv",
|
"dotenvy",
|
||||||
"diesel",
|
"diesel",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"openssl",
|
"openssl",
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
DROP INDEX playlists_owner_idx;
|
||||||
|
DROP TABLE playlists;
|
||||||
|
|
||||||
|
DROP INDEX playlist_songs_playlist_idx;
|
||||||
|
DROP TABLE playlist_songs;
|
17
migrations/2024-10-22-212759_create_playlist_tables/up.sql
Normal file
17
migrations/2024-10-22-212759_create_playlist_tables/up.sql
Normal file
@ -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);
|
@ -1,11 +1,14 @@
|
|||||||
use crate::models::Artist;
|
use crate::models::Artist;
|
||||||
use crate::components::dashboard_tile::DashboardTile;
|
use crate::components::dashboard_tile::DashboardTile;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
/// Holds information about an album
|
/// Holds information about an album
|
||||||
///
|
///
|
||||||
/// Intended to be used in the front-end
|
/// Intended to be used in the front-end
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct AlbumData {
|
pub struct AlbumData {
|
||||||
/// Album id
|
/// Album id
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
@ -36,4 +39,4 @@ impl DashboardTile for AlbumData {
|
|||||||
fn description(&self) -> Option<String> {
|
fn description(&self) -> Option<String> {
|
||||||
Some(format!("Album • {}", Artist::display_list(&self.artists)))
|
Some(format!("Album • {}", Artist::display_list(&self.artists)))
|
||||||
}
|
}
|
||||||
}
|
}
|
33
src/api/album.rs
Normal file
33
src/api/album.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use crate::albumdata::AlbumData;
|
||||||
|
use crate::songdata::SongData;
|
||||||
|
|
||||||
|
use cfg_if::cfg_if;
|
||||||
|
|
||||||
|
cfg_if! {
|
||||||
|
if #[cfg(feature = "ssr")] {
|
||||||
|
use leptos::server_fn::error::NoCustomError;
|
||||||
|
use crate::database::get_db_conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(endpoint = "album/get")]
|
||||||
|
pub async fn get_album(id: i32) -> Result<AlbumData, ServerFnError> {
|
||||||
|
use crate::models::Album;
|
||||||
|
let db_con = &mut get_db_conn();
|
||||||
|
let album = Album::get_album_data(id,db_con)
|
||||||
|
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {}", e)))?;
|
||||||
|
Ok(album)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(endpoint = "album/get_songs")]
|
||||||
|
pub async fn get_songs(id: i32) -> Result<Vec<SongData>, ServerFnError> {
|
||||||
|
use crate::models::Album;
|
||||||
|
use crate::auth::get_logged_in_user;
|
||||||
|
let user = get_logged_in_user().await?;
|
||||||
|
let db_con = &mut get_db_conn();
|
||||||
|
// TODO: NEEDS SONG DATA QUERIES
|
||||||
|
let songdata = Album::get_song_data(id,user,db_con)
|
||||||
|
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting song data: {}", e)))?;
|
||||||
|
Ok(songdata)
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod songs;
|
pub mod songs;
|
||||||
|
pub mod album;
|
||||||
|
43
src/app.rs
43
src/app.rs
@ -1,40 +1,24 @@
|
|||||||
use crate::playbar::PlayBar;
|
use crate::playbar::PlayBar;
|
||||||
use crate::playbar::CustomTitle;
|
use crate::playbar::CustomTitle;
|
||||||
use crate::playstatus::PlayStatus;
|
|
||||||
use crate::queue::Queue;
|
use crate::queue::Queue;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos::logging::*;
|
|
||||||
use leptos_meta::*;
|
use leptos_meta::*;
|
||||||
use leptos_router::*;
|
use leptos_router::*;
|
||||||
use crate::pages::login::*;
|
use crate::pages::login::*;
|
||||||
use crate::pages::signup::*;
|
use crate::pages::signup::*;
|
||||||
use crate::pages::profile::*;
|
use crate::pages::profile::*;
|
||||||
|
use crate::pages::albumpage::*;
|
||||||
use crate::error_template::{AppError, ErrorTemplate};
|
use crate::error_template::{AppError, ErrorTemplate};
|
||||||
use crate::auth::get_logged_in_user;
|
use crate::util::state::GlobalState;
|
||||||
use crate::models::User;
|
|
||||||
|
|
||||||
pub type LoggedInUserResource = Resource<(), Option<User>>;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||||
provide_meta_context();
|
provide_meta_context();
|
||||||
|
|
||||||
let play_status = PlayStatus::default();
|
provide_context(GlobalState::new());
|
||||||
let play_status = create_rw_signal(play_status);
|
|
||||||
let upload_open = create_rw_signal(false);
|
|
||||||
|
|
||||||
// A resource that fetches the logged in user
|
let upload_open = create_rw_signal(false);
|
||||||
// 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! {
|
view! {
|
||||||
// injects a stylesheet into the document <head>
|
// injects a stylesheet into the document <head>
|
||||||
@ -42,7 +26,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
|
||||||
<CustomTitle play_status=play_status/>
|
<CustomTitle />
|
||||||
|
|
||||||
// content for this welcome page
|
// content for this welcome page
|
||||||
<Router fallback=|| {
|
<Router fallback=|| {
|
||||||
@ -55,15 +39,16 @@ pub fn App() -> impl IntoView {
|
|||||||
}>
|
}>
|
||||||
<main>
|
<main>
|
||||||
<Routes>
|
<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="" view=Dashboard />
|
||||||
<Route path="dashboard" view=Dashboard />
|
<Route path="dashboard" view=Dashboard />
|
||||||
<Route path="search" view=Search />
|
<Route path="search" view=Search />
|
||||||
<Route path="user/:id" view=move || view!{ <Profile logged_in_user /> } />
|
<Route path="user/:id" view=Profile />
|
||||||
<Route path="user" view=move || view!{ <Profile logged_in_user /> } />
|
<Route path="user" view=Profile />
|
||||||
|
<Route path="album/:id" view=AlbumPage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" view=move || view!{ <Login user=logged_in_user /> } />
|
<Route path="/login" view=Login />
|
||||||
<Route path="/signup" view=move || view!{ <Signup user=logged_in_user /> } />
|
<Route path="/signup" view=Signup />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
</Router>
|
||||||
@ -78,7 +63,7 @@ use crate::components::upload::*;
|
|||||||
|
|
||||||
/// Renders the home page of your application.
|
/// Renders the home page of your application.
|
||||||
#[component]
|
#[component]
|
||||||
fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> impl IntoView {
|
fn HomePage(upload_open: RwSignal<bool>) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<Upload open=upload_open/>
|
<Upload open=upload_open/>
|
||||||
@ -86,8 +71,8 @@ fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> i
|
|||||||
// This <Outlet /> will render the child route components
|
// This <Outlet /> will render the child route components
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Personal />
|
<Personal />
|
||||||
<PlayBar status=play_status/>
|
<PlayBar />
|
||||||
<Queue status=play_status/>
|
<Queue />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,6 +87,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
|
|||||||
auth_session.logout().await
|
auth_session.logout().await
|
||||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||||
|
|
||||||
|
leptos_axum::redirect("/login");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,3 +8,4 @@ pub mod upload;
|
|||||||
pub mod song_list;
|
pub mod song_list;
|
||||||
pub mod loading;
|
pub mod loading;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod album_info;
|
25
src/components/album_info.rs
Normal file
25
src/components/album_info.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos::*;
|
||||||
|
use crate::albumdata::AlbumData;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlbumInfo(albumdata: AlbumData) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="album-info">
|
||||||
|
<img class="album-image" src={albumdata.image_path} alt="dashboard-tile" />
|
||||||
|
<div class="album-body">
|
||||||
|
<p class="album-title">{albumdata.title}</p>
|
||||||
|
<div class="album-artists">
|
||||||
|
{
|
||||||
|
albumdata.artists.iter().map(|artist| {
|
||||||
|
view! {
|
||||||
|
<a class="album-artist" href={format!("/artist/{}", artist.id.unwrap())}>{artist.name.clone()}</a>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
|||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
|
use crate::auth::logout;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Personal() -> impl IntoView {
|
pub fn Personal() -> impl IntoView {
|
||||||
@ -14,18 +16,66 @@ pub fn Personal() -> impl IntoView {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn Profile() -> impl IntoView {
|
pub fn Profile() -> impl IntoView {
|
||||||
let (dropdown_open, set_dropdown_open) = create_signal(false);
|
let (dropdown_open, set_dropdown_open) = create_signal(false);
|
||||||
|
let user = GlobalState::logged_in_user();
|
||||||
let open_dropdown = move |_| {
|
|
||||||
|
let open_dropdown = move |_| {
|
||||||
set_dropdown_open.update(|value| *value = !*value);
|
set_dropdown_open.update(|value| *value = !*value);
|
||||||
log!("opened dropdown");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let user_profile_picture = move || {
|
||||||
|
user.get().and_then(|user| {
|
||||||
|
if let Some(user) = user {
|
||||||
|
if user.id.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!("/assets/images/profile/{}.webp", user.id.unwrap()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
|
<div class="profile-name">
|
||||||
|
<Suspense
|
||||||
|
fallback=|| view!{
|
||||||
|
<h1>Not Logged In</h1>
|
||||||
|
}>
|
||||||
|
<Show
|
||||||
|
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||||
|
fallback=|| view!{
|
||||||
|
<h1>Not Logged In</h1>
|
||||||
|
}>
|
||||||
|
<h1>{move || user.get().map(|user| user.map(|user| user.username))}</h1>
|
||||||
|
</Show>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
<div class="profile-icon" on:click=open_dropdown>
|
<div class="profile-icon" on:click=open_dropdown>
|
||||||
<Icon icon=icondata::CgProfile />
|
<Suspense fallback=|| view! { <Icon icon=icondata::CgProfile width="45" height="45"/> }>
|
||||||
|
<Show
|
||||||
|
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||||
|
fallback=|| view! { <Icon icon=icondata::CgProfile width="45" height="45"/> }
|
||||||
|
>
|
||||||
|
<object class="profile-image" data={user_profile_picture} type="image/webp">
|
||||||
|
<Icon class="profile-image" icon=icondata::CgProfile width="45" height="45"/>
|
||||||
|
</object>
|
||||||
|
</Show>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-container" style={move || if dropdown_open() {"display: flex"} else {"display: none"}}>
|
<div class="dropdown-container" style={move || if dropdown_open() {"display: flex"} else {"display: none"}}>
|
||||||
<DropDownNotLoggedIn />
|
<Suspense
|
||||||
|
fallback=|| view!{
|
||||||
|
<DropDownNotLoggedIn />
|
||||||
|
}>
|
||||||
|
<Show
|
||||||
|
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||||
|
fallback=|| view!{
|
||||||
|
<DropDownNotLoggedIn />
|
||||||
|
}>
|
||||||
|
<DropDownLoggedIn />
|
||||||
|
</Show>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -33,10 +83,33 @@ pub fn Profile() -> impl IntoView {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn DropDownNotLoggedIn() -> impl IntoView {
|
pub fn DropDownNotLoggedIn() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="dropdown-not-logged">
|
<div class="dropdown-logged">
|
||||||
<h1>Not Logged in!</h1>
|
<h1>Not Logged In</h1>
|
||||||
<a href="/login"><button class="auth-button">Log In</button></a>
|
<a href="/login"><button class="auth-button">Log In</button></a>
|
||||||
<a href="/signup"><button class="auth-button">Sign up</button></a>
|
<a href="/signup"><button class="auth-button">Sign Up</button></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[component]
|
||||||
|
pub fn DropDownLoggedIn() -> impl IntoView {
|
||||||
|
|
||||||
|
let logout = move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
let result = logout().await;
|
||||||
|
if let Err(err) = result {
|
||||||
|
log!("Error logging out: {:?}", err);
|
||||||
|
} else {
|
||||||
|
let user = GlobalState::logged_in_user();
|
||||||
|
user.refetch();
|
||||||
|
log!("Logged out successfully");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="dropdown-logged">
|
||||||
|
<h1>"Logged In"</h1>
|
||||||
|
<button on:click=logout class="auth-button">Log Out</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,61 +1,88 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
|
use leptos::logging::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
|
|
||||||
|
use crate::api::songs::*;
|
||||||
use crate::songdata::SongData;
|
use crate::songdata::SongData;
|
||||||
use crate::models::{Album, Artist};
|
use crate::models::{Album, Artist};
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
|
|
||||||
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView {
|
pub fn SongList(songs: Vec<SongData>) -> impl IntoView {
|
||||||
view! {
|
__SongListInner(songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(), false)
|
||||||
<table class="song-list">
|
|
||||||
{
|
|
||||||
songs.with(|songs| {
|
|
||||||
let mut first_song = true;
|
|
||||||
|
|
||||||
songs.iter().map(|song| {
|
|
||||||
let playing = first_song.into();
|
|
||||||
first_song = false;
|
|
||||||
|
|
||||||
let extra = Option::<()>::None;
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<SongListItem song={song.clone()} song_playing=playing extra />
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SongListExtra<T>(songs: MaybeSignal<Vec<(SongData, T)>>) -> impl IntoView where
|
pub fn SongListExtra<T>(songs: Vec<(SongData, T)>) -> impl IntoView where
|
||||||
T: Clone + IntoView + 'static
|
T: Clone + IntoView + 'static
|
||||||
{
|
{
|
||||||
|
__SongListInner(songs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn SongListInner<T>(songs: Vec<(SongData, T)>, show_extra: bool) -> impl IntoView where
|
||||||
|
T: Clone + IntoView + 'static
|
||||||
|
{
|
||||||
|
let songs = Rc::new(songs);
|
||||||
|
let songs_2 = songs.clone();
|
||||||
|
|
||||||
|
// Signal that acts as a callback for a song list item to queue songs after it in the list
|
||||||
|
let (handle_queue_remaining, do_queue_remaining) = create_signal(None);
|
||||||
|
create_effect(move |_| {
|
||||||
|
let clicked_index = handle_queue_remaining.get();
|
||||||
|
|
||||||
|
if let Some(index) = clicked_index {
|
||||||
|
GlobalState::play_status().update(|status| {
|
||||||
|
let song: &(SongData, T) = songs.get(index).expect("Invalid song list item index");
|
||||||
|
|
||||||
|
if status.queue.front().map(|song| song.id) == Some(song.0.id) {
|
||||||
|
// If the clicked song is already at the front of the queue, just play it
|
||||||
|
status.playing = true;
|
||||||
|
} else {
|
||||||
|
// Otherwise, add the currently playing song to the history,
|
||||||
|
// clear the queue, and queue the clicked song and other after it
|
||||||
|
if let Some(last_playing) = status.queue.pop_front() {
|
||||||
|
status.history.push_back(last_playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
status.queue.clear();
|
||||||
|
status.queue.extend(songs.iter().skip(index).map(|(song, _)| song.clone()));
|
||||||
|
status.playing = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<table class="song-list">
|
<table class="song-list">
|
||||||
{
|
{
|
||||||
songs.with(|songs| {
|
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
|
||||||
let mut first_song = true;
|
let song_id = song.id;
|
||||||
|
let playing = create_rw_signal(false);
|
||||||
|
|
||||||
songs.iter().map(|(song, extra)| {
|
create_effect(move |_| {
|
||||||
let playing = first_song.into();
|
GlobalState::play_status().with(|status| {
|
||||||
first_song = false;
|
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<SongListItem song={song.clone()} song_playing=playing extra=Some(extra.clone()) />
|
<SongListItem song={song.clone()} song_playing=playing.into()
|
||||||
}
|
extra={if show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
|
||||||
}).collect::<Vec<_>>()
|
}
|
||||||
})
|
}).collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>) -> impl IntoView where
|
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>,
|
||||||
|
list_index: usize, do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView where
|
||||||
T: IntoView + 'static
|
T: IntoView + 'static
|
||||||
{
|
{
|
||||||
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
||||||
@ -63,14 +90,15 @@ pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: O
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<tr class="song-list-item">
|
<tr class="song-list-item">
|
||||||
<td class="song-image"><SongImage image_path=song.image_path song_playing /></td>
|
<td class="song-image"><SongImage image_path=song.image_path song_playing
|
||||||
|
list_index do_queue_remaining /></td>
|
||||||
<td class="song-title"><p>{song.title}</p></td>
|
<td class="song-title"><p>{song.title}</p></td>
|
||||||
<td class="song-list-spacer"></td>
|
<td class="song-list-spacer"></td>
|
||||||
<td class="song-artists"><SongArtists artists=song.artists /></td>
|
<td class="song-artists"><SongArtists artists=song.artists /></td>
|
||||||
<td class="song-list-spacer"></td>
|
<td class="song-list-spacer"></td>
|
||||||
<td class="song-album"><SongAlbum album=song.album /></td>
|
<td class="song-album"><SongAlbum album=song.album /></td>
|
||||||
<td class="song-list-spacer-big"></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>
|
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
|
||||||
{extra.map(|extra| view! {
|
{extra.map(|extra| view! {
|
||||||
<td class="song-list-spacer"></td>
|
<td class="song-list-spacer"></td>
|
||||||
@ -83,13 +111,27 @@ pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: O
|
|||||||
/// Display the song's image, with an overlay if the song is playing
|
/// 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
|
/// When the song list item is hovered, the overlay will show the play button
|
||||||
#[component]
|
#[component]
|
||||||
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>) -> impl IntoView {
|
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>, list_index: usize,
|
||||||
|
do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView
|
||||||
|
{
|
||||||
|
let play_song = move |_| {
|
||||||
|
do_queue_remaining.set(Some(list_index));
|
||||||
|
};
|
||||||
|
|
||||||
|
let pause_song = move |_| {
|
||||||
|
GlobalState::play_status().update(|status| {
|
||||||
|
status.playing = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<img class="song-image" src={image_path}/>
|
<img class="song-image" src={image_path}/>
|
||||||
{if song_playing.get() {
|
{move || if song_playing.get() {
|
||||||
view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view()
|
view! { <Icon class="song-image-overlay song-playing-overlay"
|
||||||
|
icon=icondata::BsPauseFill on:click=pause_song /> }.into_view()
|
||||||
} else {
|
} else {
|
||||||
view! { <Icon class="song-image-overlay hide-until-hover" icon=icondata::BsPlayFill /> }.into_view()
|
view! { <Icon class="song-image-overlay hide-until-hover"
|
||||||
|
icon=icondata::BsPlayFill on:click=play_song /> }.into_view()
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +177,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
|
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
|
||||||
#[component]
|
#[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 || {
|
let like_icon = Signal::derive(move || {
|
||||||
if liked.get() {
|
if liked.get() {
|
||||||
icondata::TbThumbUpFilled
|
icondata::TbThumbUpFilled
|
||||||
@ -168,14 +215,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 |_| {
|
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());
|
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 |_| {
|
let toggle_dislike = move |_| {
|
||||||
disliked.set(!disliked.get_untracked());
|
disliked.set(!disliked.get_untracked());
|
||||||
liked.set(liked.get_untracked() && !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! {
|
view! {
|
||||||
|
@ -30,7 +30,7 @@ async fn main() {
|
|||||||
info!("\n{}", include_str!("../ascii_art.txt"));
|
info!("\n{}", include_str!("../ascii_art.txt"));
|
||||||
info!("Starting Leptos server...");
|
info!("Starting Leptos server...");
|
||||||
|
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
debug!("Running database migrations...");
|
debug!("Running database migrations...");
|
||||||
|
174
src/models.rs
174
src/models.rs
@ -6,8 +6,10 @@ use cfg_if::cfg_if;
|
|||||||
cfg_if! {
|
cfg_if! {
|
||||||
if #[cfg(feature = "ssr")] {
|
if #[cfg(feature = "ssr")] {
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use crate::database::PgPooledConn;
|
use crate::database::*;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use crate::songdata::SongData;
|
||||||
|
use crate::albumdata::AlbumData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -498,7 +500,7 @@ impl Album {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get songs by this artist from the database
|
/// Get songs by this album from the database
|
||||||
///
|
///
|
||||||
/// The `id` field of this album must be present (Some) to get songs
|
/// The `id` field of this album must be present (Some) to get songs
|
||||||
///
|
///
|
||||||
@ -525,6 +527,153 @@ impl Album {
|
|||||||
|
|
||||||
Ok(my_songs)
|
Ok(my_songs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Obtain an album from its albumid
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `album_id` - The id of the album to select
|
||||||
|
/// * `conn` - A mutable reference to a database connection
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Album, Box<dyn Error>>` - A result indicating success with the desired album, or an error
|
||||||
|
///
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub fn get_album_data(album_id: i32, conn: &mut PgPooledConn) -> Result<AlbumData, Box<dyn Error>> {
|
||||||
|
use crate::schema::*;
|
||||||
|
|
||||||
|
let album: Vec<(Album, std::option::Option<Artist>)> = albums::table
|
||||||
|
.find(album_id)
|
||||||
|
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||||
|
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||||
|
.select((
|
||||||
|
albums::all_columns,
|
||||||
|
artists::all_columns.nullable()
|
||||||
|
))
|
||||||
|
.distinct()
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
let mut artist_list: Vec<Artist> = Vec::new();
|
||||||
|
|
||||||
|
for (_, artist) in album {
|
||||||
|
if let Some(artist) = artist {
|
||||||
|
artist_list.push(artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Get info of album
|
||||||
|
let albuminfo = albums::table
|
||||||
|
.filter(albums::id.eq(album_id))
|
||||||
|
.first::<Album>(conn)?;
|
||||||
|
|
||||||
|
let img = albuminfo.image_path.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string());
|
||||||
|
|
||||||
|
let albumdata = AlbumData {
|
||||||
|
id: albuminfo.id.unwrap(),
|
||||||
|
title: albuminfo.title,
|
||||||
|
artists: artist_list,
|
||||||
|
release_date: albuminfo.release_date,
|
||||||
|
image_path: img
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(albumdata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtain an album from its albumid
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `album_id` - The id of the album to select
|
||||||
|
/// * `conn` - A mutable reference to a database connection
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Album, Box<dyn Error>>` - A result indicating success with the desired album, or an error
|
||||||
|
///
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub fn get_song_data(album_id: i32, user_like_dislike: Option<User>, conn: &mut PgPooledConn) -> Result<Vec<SongData>, Box<dyn Error>> {
|
||||||
|
use crate::schema::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let song_list = if let Some(user_like_dislike) = user_like_dislike {
|
||||||
|
let user_like_dislike_id = user_like_dislike.id.unwrap();
|
||||||
|
let song_list: Vec<(Album, Option<Song>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> =
|
||||||
|
albums::table
|
||||||
|
.find(album_id)
|
||||||
|
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||||
|
.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(user_like_dislike_id))))
|
||||||
|
.left_join(song_dislikes::table.on(songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(user_like_dislike_id))))
|
||||||
|
.select((
|
||||||
|
albums::all_columns,
|
||||||
|
songs::all_columns.nullable(),
|
||||||
|
artists::all_columns.nullable(),
|
||||||
|
song_likes::all_columns.nullable(),
|
||||||
|
song_dislikes::all_columns.nullable()
|
||||||
|
))
|
||||||
|
.order(songs::track.asc())
|
||||||
|
.load(conn)?;
|
||||||
|
song_list
|
||||||
|
} else {
|
||||||
|
let song_list: Vec<(Album, Option<Song>, Option<Artist>)> =
|
||||||
|
albums::table
|
||||||
|
.find(album_id)
|
||||||
|
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||||
|
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||||
|
.select((
|
||||||
|
albums::all_columns,
|
||||||
|
songs::all_columns.nullable(),
|
||||||
|
artists::all_columns.nullable()
|
||||||
|
))
|
||||||
|
.order(songs::track.asc())
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
let song_list: Vec<(Album, Option<Song>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> =
|
||||||
|
song_list.into_iter().map( |(album, song, artist)| (album, song, artist, None, None) ).collect();
|
||||||
|
song_list
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut album_songs: HashMap<i32, SongData> = HashMap::with_capacity(song_list.len());
|
||||||
|
|
||||||
|
for (album, song, artist, like, dislike) in song_list {
|
||||||
|
if let Some(song) = song {
|
||||||
|
if let Some(stored_songdata) = album_songs.get_mut(&song.id.unwrap()) {
|
||||||
|
// 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.image_path.clone().unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
|
||||||
|
|
||||||
|
let songdata = SongData {
|
||||||
|
id: song.id.unwrap(),
|
||||||
|
title: song.title,
|
||||||
|
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||||
|
album: Some(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,
|
||||||
|
};
|
||||||
|
|
||||||
|
album_songs.insert(song.id.unwrap(), songdata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the songs by date
|
||||||
|
let mut songdata: Vec<SongData> = album_songs.into_values().collect();
|
||||||
|
songdata.sort_by(|a, b| b.track.cmp(&a.track));
|
||||||
|
Ok(songdata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Model for a song
|
/// Model for a song
|
||||||
@ -625,3 +774,24 @@ pub struct HistoryEntry {
|
|||||||
/// The id of the song that was listened to
|
/// The id of the song that was listened to
|
||||||
pub song_id: i32,
|
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 = NaiveDateTime))]
|
||||||
|
pub created_at: Option<NaiveDateTime>,
|
||||||
|
/// The time the playlist was last updated
|
||||||
|
#[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
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod signup;
|
pub mod signup;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod albumpage;
|
||||||
|
88
src/pages/albumpage.rs
Normal file
88
src/pages/albumpage.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
use crate::components::song_list::*;
|
||||||
|
use crate::api::album::*;
|
||||||
|
use crate::components::album_info::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Params, PartialEq)]
|
||||||
|
struct AlbumParams {
|
||||||
|
id: i32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlbumPage() -> impl IntoView {
|
||||||
|
let params = use_params::<AlbumParams>();
|
||||||
|
|
||||||
|
let id = move || {params.with(|params| {
|
||||||
|
params.as_ref()
|
||||||
|
.map(|params| params.id)
|
||||||
|
.map_err(|e| e.clone())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let song_list = create_resource(
|
||||||
|
id,
|
||||||
|
|value| async move {
|
||||||
|
match value {
|
||||||
|
Ok(v) => {get_songs(v).await},
|
||||||
|
Err(e) => {Err(ServerFnError::Request(format!("Error getting song data: {}", e).into()))},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let albumdata = create_resource(
|
||||||
|
id,
|
||||||
|
|value| async move {
|
||||||
|
match value {
|
||||||
|
Ok(v) => {get_album(v).await},
|
||||||
|
Err(e) => {Err(ServerFnError::Request(format!("Error getting song data: {}", e).into()))},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="album-page-container">
|
||||||
|
<div class="album-header">
|
||||||
|
<Suspense
|
||||||
|
fallback=move || view! { <p class="loading">"Loading..."</p> }
|
||||||
|
>
|
||||||
|
{move || {
|
||||||
|
albumdata.with( |albumdata| {
|
||||||
|
match albumdata {
|
||||||
|
Some(Ok(s)) => {
|
||||||
|
view! { <AlbumInfo albumdata=(*s).clone() /> }
|
||||||
|
},
|
||||||
|
Some(Err(e)) => {
|
||||||
|
view! { <div class="error">{format!("Error loading album : {}",e)}</div> }.into_view()
|
||||||
|
},
|
||||||
|
None => {view! { }.into_view()}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div class="album-songs">
|
||||||
|
<Suspense
|
||||||
|
fallback=move || view! { <p class="loading">"Loading..."</p> }
|
||||||
|
>
|
||||||
|
{move || {
|
||||||
|
song_list.with( |song_list| {
|
||||||
|
match song_list {
|
||||||
|
Some(Ok(s)) => {
|
||||||
|
view! { <SongList songs=(*s).clone()/> }
|
||||||
|
},
|
||||||
|
Some(Err(e)) => {
|
||||||
|
view! { <div class="error">{format!("Error loading albums: : {}",e)}</div> }.into_view()
|
||||||
|
},
|
||||||
|
None => {view! { }.into_view()}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
|||||||
use crate::auth::login;
|
use crate::auth::login;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
use crate::users::UserCredentials;
|
use crate::users::UserCredentials;
|
||||||
use crate::app::LoggedInUserResource;
|
|
||||||
|
|
||||||
#[component]
|
#[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 (username_or_email, set_username_or_email) = create_signal("".to_string());
|
||||||
let (password, set_password) = 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,
|
username_or_email: username_or_email1,
|
||||||
password: password1
|
password: password1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let user = GlobalState::logged_in_user();
|
||||||
|
|
||||||
let login_result = login(user_credentials).await;
|
let login_result = login(user_credentials).await;
|
||||||
if let Err(err) = login_result {
|
if let Err(err) = login_result {
|
||||||
|
@ -11,9 +11,9 @@ use crate::components::error::*;
|
|||||||
|
|
||||||
use crate::api::profile::*;
|
use crate::api::profile::*;
|
||||||
|
|
||||||
use crate::app::LoggedInUserResource;
|
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
use crate::users::get_user_by_id;
|
use crate::users::get_user_by_id;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
|
|
||||||
/// Duration in seconds backwards from now to aggregate history data for
|
/// Duration in seconds backwards from now to aggregate history data for
|
||||||
const HISTORY_SECS: i64 = 60 * 60 * 24 * 30;
|
const HISTORY_SECS: i64 = 60 * 60 * 24 * 30;
|
||||||
@ -29,7 +29,7 @@ const TOP_ARTISTS_COUNT: i64 = 10;
|
|||||||
/// Profile page
|
/// 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
|
/// 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]
|
#[component]
|
||||||
pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
pub fn Profile() -> impl IntoView {
|
||||||
let params = use_params_map();
|
let params = use_params_map();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
@ -38,7 +38,7 @@ pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
|||||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||||
None => {
|
None => {
|
||||||
// No id specified, show the current user's profile
|
// No id specified, show the current user's profile
|
||||||
view! { <OwnProfile logged_in_user /> }.into_view()
|
view! { <OwnProfile /> }.into_view()
|
||||||
},
|
},
|
||||||
Some(Ok(id)) => {
|
Some(Ok(id)) => {
|
||||||
// Id specified, get the user and show their profile
|
// 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
|
/// Show the logged in user's profile
|
||||||
#[component]
|
#[component]
|
||||||
fn OwnProfile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
fn OwnProfile() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<Transition
|
<Transition
|
||||||
fallback=move || view! { <LoadingPage /> }
|
fallback=move || view! { <LoadingPage /> }
|
||||||
>
|
>
|
||||||
{move || logged_in_user.get().map(|user| {
|
{move || GlobalState::logged_in_user().get().map(|user| {
|
||||||
match user {
|
match user {
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
let user_id = user.id.unwrap();
|
let user_id = user.id.unwrap();
|
||||||
@ -214,7 +214,7 @@ fn TopSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
|||||||
top_songs.get().map(|top_songs| {
|
top_songs.get().map(|top_songs| {
|
||||||
top_songs.map(|top_songs| {
|
top_songs.map(|top_songs| {
|
||||||
view! {
|
view! {
|
||||||
<SongListExtra songs={top_songs.into()} />
|
<SongListExtra songs=top_songs />
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -255,7 +255,7 @@ fn RecentSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
|||||||
recent_songs.get().map(|recent_songs| {
|
recent_songs.get().map(|recent_songs| {
|
||||||
recent_songs.map(|recent_songs| {
|
recent_songs.map(|recent_songs| {
|
||||||
view! {
|
view! {
|
||||||
<SongList songs={recent_songs.into()} />
|
<SongList songs=recent_songs />
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
use crate::auth::signup;
|
use crate::auth::signup;
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
use crate::app::LoggedInUserResource;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
|
pub fn Signup() -> impl IntoView {
|
||||||
let (username, set_username) = create_signal("".to_string());
|
let (username, set_username) = create_signal("".to_string());
|
||||||
let (email, set_email) = create_signal("".to_string());
|
let (email, set_email) = create_signal("".to_string());
|
||||||
let (password, set_password) = 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);
|
log!("new user: {:?}", new_user);
|
||||||
|
|
||||||
|
let user = GlobalState::logged_in_user();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Err(err) = signup(new_user.clone()).await {
|
if let Err(err) = signup(new_user.clone()).await {
|
||||||
// Handle the error here, e.g., log it or display to the user
|
// Handle the error here, e.g., log it or display to the user
|
||||||
|
102
src/playbar.rs
102
src/playbar.rs
@ -1,7 +1,7 @@
|
|||||||
use crate::models::Artist;
|
use crate::models::Artist;
|
||||||
use crate::playstatus::PlayStatus;
|
|
||||||
use crate::songdata::SongData;
|
use crate::songdata::SongData;
|
||||||
use crate::api::songs;
|
use crate::api::songs;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
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::*;
|
||||||
@ -40,8 +40,8 @@ const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64;
|
|||||||
/// * `None` if the audio element is not available
|
/// * `None` if the audio element is not available
|
||||||
/// * `Some((current_time, duration))` if the audio element is 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)> {
|
pub fn get_song_time_duration() -> Option<(f64, f64)> {
|
||||||
status.with_untracked(|status| {
|
GlobalState::play_status().with_untracked(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
Some((audio.current_time(), audio.duration()))
|
Some((audio.current_time(), audio.duration()))
|
||||||
} else {
|
} 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
|
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||||
/// * `time` - The time to skip to, in seconds
|
/// * `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() {
|
if time.is_infinite() || time.is_nan() {
|
||||||
error!("Unable to skip to non-finite time: {}", time);
|
error!("Unable to skip to non-finite time: {}", time);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
audio.set_current_time(time);
|
audio.set_current_time(time);
|
||||||
log!("Player skipped to 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
|
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||||
/// * `play` - `true` to play the song, `false` to pause it
|
/// * `play` - `true` to play the song, `false` to pause it
|
||||||
///
|
///
|
||||||
pub fn set_playing(status: impl SignalUpdate<Value = PlayStatus>, play: bool) {
|
pub fn set_playing(play: bool) {
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
if play {
|
if play {
|
||||||
if let Err(e) = audio.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>) {
|
fn toggle_queue() {
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
status.queue_open = !status.queue_open;
|
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
|
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||||
/// * `src` - The source to set the audio player to
|
/// * `src` - The source to set the audio player to
|
||||||
///
|
///
|
||||||
fn set_play_src(status: impl SignalUpdate<Value = PlayStatus>, src: String) {
|
fn set_play_src(src: String) {
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
audio.set_src(&src);
|
audio.set_src(&src);
|
||||||
log!("Player set src to: {}", 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
|
/// The play, pause, and skip buttons
|
||||||
#[component]
|
#[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
|
// On click handlers for the skip and play/pause buttons
|
||||||
|
|
||||||
let skip_back = move |_| {
|
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
|
// 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
|
// 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
|
// 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
|
// Push the popped song to the front of the queue, and play it
|
||||||
let next_src = last_played_song.song_path.clone();
|
let next_src = last_played_song.song_path.clone();
|
||||||
status.update(|status| status.queue.push_front(last_played_song));
|
status.update(|status| status.queue.push_front(last_played_song));
|
||||||
set_play_src(status, next_src);
|
set_play_src(next_src);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
warn!("Unable to skip back: No previous song");
|
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
|
// Default to skipping to start of current song, and playing
|
||||||
log!("Skipping to start of current song");
|
log!("Skipping to start of current song");
|
||||||
skip_to(status, 0.0);
|
skip_to(0.0);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
let skip_forward = move |_| {
|
let skip_forward = move |_| {
|
||||||
if let Some(duration) = get_song_time_duration(status) {
|
if let Some(duration) = get_song_time_duration() {
|
||||||
skip_to(status, duration.1);
|
skip_to(duration.1);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip forward: Unable to get current duration");
|
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 toggle_play = move |_| {
|
||||||
let playing = status.with_untracked(|status| { status.playing });
|
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
|
// 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
|
/// The name, artist, and album of the current song
|
||||||
#[component]
|
#[component]
|
||||||
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn MediaInfo() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
let name = Signal::derive(move || {
|
let name = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
|
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
|
/// The like and dislike buttons
|
||||||
#[component]
|
#[component]
|
||||||
fn LikeDislike(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn LikeDislike() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
let like_icon = Signal::derive(move || {
|
let like_icon = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
match status.queue.front() {
|
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
|
/// The play progress bar, and click handler for skipping to a certain time in the song
|
||||||
#[component]
|
#[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
|
// 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>();
|
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 width = progress_bar.offset_width() as f64;
|
||||||
let percentage = x_click_pos / width * 100.0;
|
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;
|
let time = duration.1 * percentage / 100.0;
|
||||||
skip_to(status, time);
|
skip_to(time);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip to time: Unable to get current duration");
|
error!("Unable to skip to time: Unable to get current duration");
|
||||||
}
|
}
|
||||||
@ -438,13 +444,13 @@ fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> im
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn QueueToggle() -> impl IntoView {
|
||||||
|
|
||||||
let update_queue = move |_| {
|
let update_queue = move |_| {
|
||||||
toggle_queue(status);
|
toggle_queue();
|
||||||
log!("queue button pressed, queue status: {:?}", status.with_untracked(|status| status.queue_open));
|
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
|
// We use this to prevent the buttons from being focused when clicked
|
||||||
// If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button
|
// If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button
|
||||||
// and trigger unwanted behavior
|
// and trigger unwanted behavior
|
||||||
@ -463,9 +469,9 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
/// Renders the title of the page based on the currently playing song
|
/// Renders the title of the page based on the currently playing song
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView {
|
pub fn CustomTitle() -> impl IntoView {
|
||||||
let title = create_memo(move |_| {
|
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| {
|
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| {
|
||||||
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
|
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
|
/// 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() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
// Listen for key down events -- arrow keys don't seem to trigger key press events
|
// 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| {
|
let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| {
|
||||||
if e.key() == "ArrowRight" {
|
if e.key() == "ArrowRight" {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
|
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;
|
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
|
||||||
time = time.clamp(0.0, duration.1);
|
time = time.clamp(0.0, duration.1);
|
||||||
skip_to(status, time);
|
skip_to(time);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip forward: Unable to get current duration");
|
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();
|
e.prevent_default();
|
||||||
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
|
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;
|
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
|
||||||
time = time.clamp(0.0, duration.1);
|
time = time.clamp(0.0, duration.1);
|
||||||
skip_to(status, time);
|
skip_to(time);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip backward: Unable to get current duration");
|
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");
|
log!("Space bar pressed, toggling play/pause");
|
||||||
|
|
||||||
let playing = status.with_untracked(|status| status.playing);
|
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
|
<audio _ref=audio_ref on:play=on_play on:pause=on_pause
|
||||||
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
||||||
<div class="playbar">
|
<div class="playbar">
|
||||||
<ProgressBar percentage=percentage.into() status=status />
|
<ProgressBar percentage=percentage.into() />
|
||||||
<div class="playbar-left-group">
|
<div class="playbar-left-group">
|
||||||
<MediaInfo status=status />
|
<MediaInfo />
|
||||||
<LikeDislike status=status />
|
<LikeDislike />
|
||||||
</div>
|
</div>
|
||||||
<PlayControls status=status />
|
<PlayControls />
|
||||||
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
||||||
<QueueToggle status=status />
|
<QueueToggle />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
src/queue.rs
11
src/queue.rs
@ -1,6 +1,6 @@
|
|||||||
use crate::models::Artist;
|
use crate::models::Artist;
|
||||||
use crate::playstatus::PlayStatus;
|
|
||||||
use crate::song::Song;
|
use crate::song::Song;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::ev::MouseEvent;
|
use leptos::ev::MouseEvent;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
@ -9,22 +9,23 @@ use leptos::ev::DragEvent;
|
|||||||
|
|
||||||
const RM_BTN_SIZE: &str = "2.5rem";
|
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 {
|
if index == 0 {
|
||||||
log!("Error: Trying to remove currently playing song (index 0) from queue");
|
log!("Error: Trying to remove currently playing song (index 0) from queue");
|
||||||
} else {
|
} else {
|
||||||
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
|
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);
|
status.queue.remove(index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[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| {
|
let remove_song = move |index: usize| {
|
||||||
remove_song_fn(index, status);
|
remove_song_fn(index);
|
||||||
log!("Removed song {}", index + 1);
|
log!("Removed song {}", index + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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! {
|
diesel::table! {
|
||||||
song_artists (song_id, artist_id) {
|
song_artists (song_id, artist_id) {
|
||||||
song_id -> Int4,
|
song_id -> Int4,
|
||||||
@ -95,6 +112,9 @@ diesel::table! {
|
|||||||
|
|
||||||
diesel::joinable!(album_artists -> albums (album_id));
|
diesel::joinable!(album_artists -> albums (album_id));
|
||||||
diesel::joinable!(album_artists -> artists (artist_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 -> artists (artist_id));
|
||||||
diesel::joinable!(song_artists -> songs (song_id));
|
diesel::joinable!(song_artists -> songs (song_id));
|
||||||
diesel::joinable!(song_dislikes -> songs (song_id));
|
diesel::joinable!(song_dislikes -> songs (song_id));
|
||||||
@ -111,6 +131,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
artists,
|
artists,
|
||||||
friend_requests,
|
friend_requests,
|
||||||
friendships,
|
friendships,
|
||||||
|
playlist_songs,
|
||||||
|
playlists,
|
||||||
song_artists,
|
song_artists,
|
||||||
song_dislikes,
|
song_dislikes,
|
||||||
song_history,
|
song_history,
|
||||||
|
@ -117,7 +117,7 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>,
|
|||||||
|
|
||||||
/// Get a user from the database by username or email
|
/// Get a user from the database by username or email
|
||||||
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
||||||
#[server(endpoint = "get_user")]
|
#[server(endpoint = "find_user")]
|
||||||
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
|
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
|
||||||
let mut user = find_user(username_or_email).await?;
|
let mut user = find_user(username_or_email).await?;
|
||||||
|
|
||||||
|
@ -5,3 +5,5 @@ cfg_if! {
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod state;
|
||||||
|
49
src/util/state.rs
Normal file
49
src/util/state.rs
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
95
style/album_page.scss
Normal file
95
style/album_page.scss
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
@import 'theme.scss';
|
||||||
|
|
||||||
|
.album-page-container {
|
||||||
|
$album-header-height: calc(40% - 2px);
|
||||||
|
|
||||||
|
width: $center-viewport-width;
|
||||||
|
max-height: $center-viewport-height;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
margin-top:2px;
|
||||||
|
|
||||||
|
.album-header {
|
||||||
|
$album-header-padding: 20px;
|
||||||
|
|
||||||
|
height: $album-header-height;
|
||||||
|
width: calc(100% - 2*$album-header-padding);
|
||||||
|
margin: 2px;
|
||||||
|
|
||||||
|
padding: $album-header-padding;
|
||||||
|
|
||||||
|
background-image: linear-gradient($accent-color, $background-color);
|
||||||
|
border-radius: 15px;
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-songs {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100% - $album-header-height);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
|
||||||
|
.album-image {
|
||||||
|
max-width: 80%;
|
||||||
|
max-height: 80%;
|
||||||
|
box-shadow: 10px 10px 50px -10px $background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-body {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
color: $text-controls-color;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin:15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-artists {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-content: space-around;
|
||||||
|
margin:15px;
|
||||||
|
|
||||||
|
color: $text-controls-color;
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
|
.album-artist {
|
||||||
|
margin: 5px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $text-controls-color;
|
||||||
|
}
|
||||||
|
a:visited {
|
||||||
|
color: $text-controls-color;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: $controls-hover-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
color: $controls-click-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
|
max-width: $center-viewport-width;
|
||||||
|
max-height: $center-viewport-height;
|
||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
.home-container {
|
.home-container {
|
||||||
margin-top: 0;
|
width: 100vw;
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-component {
|
.home-component {
|
||||||
background: #1c1c1c;
|
background: #1c1c1c;
|
||||||
width: calc(100% - 22rem - 16rem);
|
|
||||||
|
width: calc($center-viewport-width - 4px - 2rem);
|
||||||
|
height: calc($center-viewport-height - 4px - 1rem);
|
||||||
|
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
padding: 0.2rem 1.5rem $playbar-size 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@
|
|||||||
@import 'song_list.scss';
|
@import 'song_list.scss';
|
||||||
@import 'profile.scss';
|
@import 'profile.scss';
|
||||||
@import 'loading.scss';
|
@import 'loading.scss';
|
||||||
|
@import 'album_page.scss';
|
||||||
|
@import 'scrollbar.scss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
.personal-container {
|
.personal-container {
|
||||||
width: 16rem;
|
width: $personal-width;
|
||||||
background: #1c1c1c;
|
background: #1c1c1c;
|
||||||
margin: 2px;
|
margin: $personal-margin;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
.profile-container {
|
.profile-container {
|
||||||
@ -11,9 +11,19 @@
|
|||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
margin: 0.2rem;
|
margin: 0.2rem;
|
||||||
min-height: 6rem;
|
min-height: 6rem;
|
||||||
border: 2px solid rgba(89, 89, 89, 0.199);
|
border: 0.2rem solid rgba(89, 89, 89, 0.199);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-icon {
|
.profile-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
@ -21,17 +31,23 @@
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
height: max-content;
|
height: 45;
|
||||||
|
width: 45;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
.profile-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-icon:hover {
|
.profile-icon:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.05);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-icon:active {
|
.profile-icon:active {
|
||||||
transform: scale(0.8);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
.dropdown-container {
|
.dropdown-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -41,10 +57,10 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
width: 10rem;
|
width: 10rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: red;
|
background-color: #1c1c1c;
|
||||||
border: 1px solid grey;
|
border: 0.2rem solid rgba(89, 89, 89, 0.199);
|
||||||
|
|
||||||
.dropdown-not-logged {
|
.dropdown-logged {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -53,21 +69,29 @@
|
|||||||
h1 {
|
h1 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
border-top: 0.2rem solid rgba(89, 89, 89, 0.199);
|
||||||
|
border-bottom: 0.2rem solid rgba(89, 89, 89, 0.199);
|
||||||
|
h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
.auth-button {
|
.auth-button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
border: 0.2rem solid rgba(89, 89, 89, 0.199);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dropdown-container:before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: -0.4rem;
|
|
||||||
right: 0.92rem;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
background-color: red;
|
|
||||||
border-left: 1px solid grey;
|
|
||||||
border-top: 1px solid grey;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12
style/scrollbar.scss
Normal file
12
style/scrollbar.scss
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
background-blend-mode: $scrollbar-background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: $scrollbar-handle-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: $scrollbar-handle-hover-color;
|
||||||
|
}
|
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
width: 22rem;
|
width: $sidebar-width;
|
||||||
height: calc(100% - 75px);
|
height: $center-viewport-height;
|
||||||
|
margin: 2px;
|
||||||
|
|
||||||
.sidebar-top-container {
|
.sidebar-top-container {
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background-color: #1c1c1c;
|
background-color: #1c1c1c;
|
||||||
height: 9rem;
|
height: 9rem;
|
||||||
margin: 3px;
|
margin: 2px 0px;
|
||||||
padding: 0.1rem 1rem 1rem 1rem;
|
padding: 0.1rem 1rem 1rem 1rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
.header {
|
.header {
|
||||||
@ -68,7 +70,7 @@
|
|||||||
.sidebar-bottom-container {
|
.sidebar-bottom-container {
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background-color: #1c1c1c;
|
background-color: #1c1c1c;
|
||||||
margin: 3px;
|
margin: 2px 0px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 0.2rem 1rem 1rem 1rem;
|
padding: 0.2rem 1rem 1rem 1rem;
|
||||||
height: calc(100% - 9rem);
|
height: calc(100% - 9rem);
|
||||||
|
@ -10,10 +10,23 @@ $controls-click-color: #909090;
|
|||||||
$play-bar-background-color: #212121;
|
$play-bar-background-color: #212121;
|
||||||
$play-grad-start: #0a0533;
|
$play-grad-start: #0a0533;
|
||||||
$play-grad-end: $accent-color;
|
$play-grad-end: $accent-color;
|
||||||
|
$border-color: #7851ed;
|
||||||
$queue-background-color: $play-bar-background-color;
|
$queue-background-color: $play-bar-background-color;
|
||||||
|
|
||||||
|
$scrollbar-background-color: #101010;
|
||||||
|
$scrollbar-handle-color:#292929;
|
||||||
|
$scrollbar-handle-hover-color:#5c5b5b;
|
||||||
|
|
||||||
$auth-inputs: #796dd4;
|
$auth-inputs: #796dd4;
|
||||||
$auth-containers: white;
|
$auth-containers: white;
|
||||||
|
|
||||||
$dashboard-tile-size: 200px;
|
$dashboard-tile-size: 200px;
|
||||||
$playbar-size: 75px;
|
$playbar-size: 75px;
|
||||||
|
$personal-width: 16rem;
|
||||||
|
$sidebar-width: 22rem;
|
||||||
|
|
||||||
|
$personal-margin: 2px;
|
||||||
|
$sidebar-margin: 2px;
|
||||||
|
|
||||||
|
$center-viewport-width: calc(100vw - $personal-width - $sidebar-width - 2*$personal-margin - 2*$sidebar-margin);
|
||||||
|
$center-viewport-height: calc(100vh - $playbar-size);
|
Reference in New Issue
Block a user