Compare commits

...

55 Commits

Author SHA1 Message Date
91cd6737f9 Fixed enter key issue 2024-12-11 04:46:27 +00:00
1a1516ff92 Merge pull request 'Set new container registry url in docker-compose' (#156) from 112-set-new-container-registry-url-in into main
Reviewed-on: LibreTunes/LibreTunes#156
2024-11-23 07:16:40 +00:00
c9d3053c5a Change registry URL to Gitea registry 2024-11-23 02:02:53 -05:00
aced8723c2 Merge pull request 'Indicate loading and errors on login/signup pages' (#154) from 147-indicate-loading-and-errors-on-loginsignup into main
Reviewed-on: LibreTunes/LibreTunes#154
2024-11-23 06:15:25 +00:00
ede248d961 Add loading and error messages to signup page 2024-11-23 00:57:29 -05:00
930618dcad Add loading and error messages to login page 2024-11-23 00:46:30 -05:00
9b48fc0204 Merge pull request '95-fix-home-screen-account-button-ui' (#124) from 95-fix-home-screen-account-button-ui into main
Reviewed-on: https://gitea.mregirouard.com/LibreTunes/LibreTunes/pulls/124
2024-11-22 22:32:53 +00:00
dfbb217988 Merge pull request '114-create-album-page' (#149) from 114-create-album-page into main
Reviewed-on: https://gitea.mregirouard.com/LibreTunes/LibreTunes/pulls/149
2024-11-22 22:31:27 +00:00
954cc0edce Artist links and styling 2024-11-22 22:13:43 +00:00
e5ce0eab76 Fix Build Warnings 2024-11-22 21:55:59 +00:00
45eb7191f0 Bugfix and small changes to styling 2024-11-22 21:47:24 +00:00
21a17a8eb5 Album Page Styling 2024-11-22 21:34:21 +00:00
fcd987d433 change unwrap or default to unwrap or false for readability 2024-11-22 16:31:55 -05:00
dd14aa0b4d AlbumData Query, API Endpoint, and Integration into AlbumPage 2024-11-20 04:43:53 +00:00
3b6035dd71 Album pages for users not signed in 2024-11-20 02:12:09 +00:00
22cee4a265 Merge remote-tracking branch 'origin/main' into 114-create-album-page 2024-11-19 22:21:46 +00:00
b25cb4549c Revert get_user to not be server function 2024-11-19 16:21:35 -05:00
b1a62ee284 Merge pull request 'Switch from dotenv to dotenvy' (#146) from 127-switch-from-dotenv-to-dotenvy into main
Reviewed-on: LibreTunes/LibreTunes#146
2024-11-19 00:44:27 +00:00
0c1bab9fbb Switch from dotenv to dotenvy 2024-11-18 18:34:20 -05:00
190df0edfd Merge pull request 'Implement play/pause for songs in SongList' (#145) from 122-implement-playpause-for-songs-in-songlist into main
Reviewed-on: LibreTunes/LibreTunes#145
2024-11-18 00:13:47 +00:00
73b4b7674e Use global state user instead of local resource in personal page 2024-11-17 18:42:57 -05:00
3d1dc94b06 Merge remote-tracking branch 'origin/main' into 95-fix-home-screen-account-button-ui 2024-11-17 17:38:29 -05:00
a3b3174306 Handle SongListItem play/pause clicks 2024-11-17 15:39:08 -05:00
f633cffe69 Remove unnecessary .into() 2024-11-15 22:45:47 -05:00
cf6f7b7db7 Refactor SongList/SongListExtra
Don't use MaybeSignal
Combine into SongListInner
2024-11-15 22:44:57 -05:00
8f01ff24d8 Merge pull request 'Use provide_context for global state' (#143) from 142-use-providecontext-for-global-state into main
Reviewed-on: https://gitea.mregirouard.com/LibreTunes/LibreTunes/pulls/143
2024-11-16 01:13:25 +00:00
f0f34d4abe Use GlobalState instead of passing play_status/logged_in_user everywhere 2024-11-15 18:49:19 -05:00
d42737f856 Create GlobalState 2024-11-15 18:47:13 -05:00
47fc35814d Merge pull request 'Implement SongList like/dislike functionality' (#141) from 123-implement-likedislike-for-songs-in-songlist into main
Reviewed-on: LibreTunes/LibreTunes#141
2024-11-15 21:45:06 +00:00
fe21785f2d Implement SongList like/dislike functionality 2024-11-15 15:33:05 -05:00
c6024a7457 Merge pull request 'Update wasm-bindgen to 0.2.95' (#139) from 132-update-wasmbindgen-to-0295 into main
Reviewed-on: LibreTunes/LibreTunes#139
2024-11-15 18:42:18 +00:00
41327c8ed6 Update wasm-bindgen to 0.2.95 2024-11-15 13:23:20 -05:00
d09060959a Merge pull request '111-create-playlist-database-tables' (#137) from 111-create-playlist-database-tables into main
Reviewed-on: LibreTunes/LibreTunes#137
Reviewed-by: Ethan Girouard <ethan@girouard.com>
2024-11-12 22:21:52 +00:00
d8fab0e068 Reduce calls to get_user by not having logged_in resource 2024-11-12 17:11:06 -05:00
7f298f75ce Change Playlist Model to use chronos time 2024-11-12 17:09:08 -05:00
74044f2e17 Merge remote-tracking branch 'origin/134-fix-time-not-implemented-on-wasm' into 111-create-playlist-database-tables 2024-11-12 17:06:52 -05:00
568f6ada0e Working Albums Page (BETA) 2024-11-12 21:44:30 +00:00
7a1ffaad47 Finished Query for Song Data from Album 2024-11-08 22:16:46 +00:00
f1affc66bc Remove email display on personal and rescale icon/pfp to be bigger & consistent 2024-11-01 17:17:59 -04:00
e60243e50c Redirect to login when logging out 2024-11-01 17:17:06 -04:00
0335e3d255 Created model 2024-11-01 16:48:56 -04:00
81b1490cce Create playlist database migration 2024-11-01 16:37:22 -04:00
ff24f68eed A very big SQL Query 2024-10-26 03:53:03 +00:00
6aa933be09 Refactor profile picture display to account for no profile picture 2024-10-25 22:59:06 -04:00
fe1b76e6e4 API Endpoints for Album Queries 2024-10-25 21:23:21 +00:00
acf15961cd Fix formatting and styling of profile picture display, as well as display email when logged in as well 2024-10-25 17:01:50 -04:00
df01bafbd1 Album Page Component 2024-10-22 03:32:55 +00:00
2d7b91413b Display username in profile container when logged in 2024-10-18 19:31:20 -04:00
c2ebd8307f Display user profile picture when logged in instead of generic profile icon 2024-10-18 19:30:45 -04:00
2be665c549 Track logged in status and user as local resource instead of signal 2024-10-18 19:26:10 -04:00
f104a14f98 Remove derive Default for User and make user signal Option type instead 2024-10-15 17:26:30 -04:00
f78066d7a8 Display username in profile when logged in 2024-10-15 17:18:41 -04:00
071dcad0cc Convert borders and margins to rem from px in profile 2024-10-15 17:17:44 -04:00
3149f65a97 Detect user logged in and display "logged in" for profile 2024-10-11 17:21:11 -04:00
ab50826d31 Update formatting of profile login buttons and display 2024-10-04 17:44:10 -04:00
33 changed files with 935 additions and 204 deletions

32
Cargo.lock generated
View File

@ -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"

View File

@ -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",

View File

@ -3,7 +3,7 @@ name: libretunes
services: services:
libretunes: libretunes:
container_name: libretunes container_name: libretunes
# image: registry.mregirouard.com/libretunes/libretunes:latest # image: git.libretunes.xyz/libretunes/libretunes:latest
build: . build: .
ports: ports:
- "3000:3000" - "3000:3000"

View File

@ -0,0 +1,5 @@
DROP INDEX playlists_owner_idx;
DROP TABLE playlists;
DROP INDEX playlist_songs_playlist_idx;
DROP TABLE playlist_songs;

View 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);

View File

@ -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,

33
src/api/album.rs Normal file
View 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)
}

View File

@ -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;

View File

@ -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>
} }
} }

View File

@ -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(())
} }

View File

@ -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;

View 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()
}

View File

@ -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>
} }
} }

View File

@ -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! {

View File

@ -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...");

View File

@ -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,
}

View File

@ -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;

87
src/pages/albumpage.rs Normal file
View File

@ -0,0 +1,87 @@
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>
<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>
}
}

View File

@ -1,20 +1,25 @@
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; use crate::components::loading::Loading;
#[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());
let (show_password, set_show_password) = create_signal(false); let (show_password, set_show_password) = create_signal(false);
let toggle_password = move |_| { let loading = create_rw_signal(false);
let error_msg = create_rw_signal(None);
let toggle_password = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
set_show_password.update(|show_password| *show_password = !*show_password); set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password"); log!("Password visibility toggled");
}; };
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
@ -24,15 +29,21 @@ pub fn Login(user: LoggedInUserResource) -> impl IntoView {
let password1 = password.get(); let password1 = password.get();
spawn_local(async move { spawn_local(async move {
loading.set(true);
error_msg.set(None);
let user_credentials = UserCredentials { let user_credentials = UserCredentials {
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 {
// 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
log!("Error logging in: {:?}", err); log!("Error logging in: {:?}", err);
error_msg.set(Some(err.to_string()));
// Since we're not sure what the state is, manually refetch the user // Since we're not sure what the state is, manually refetch the user
user.refetch(); user.refetch();
@ -46,10 +57,13 @@ pub fn Login(user: LoggedInUserResource) -> impl IntoView {
log!("Navigated to home page after login"); log!("Navigated to home page after login");
} else if let Ok(None) = login_result { } else if let Ok(None) = login_result {
log!("Invalid username or password"); log!("Invalid username or password");
error_msg.set(Some("Invalid username or password".to_string()));
// User could be already logged in or not, so refetch the user // User could be already logged in or not, so refetch the user
user.refetch(); user.refetch();
} }
loading.set(false);
}); });
}; };
@ -81,20 +95,26 @@ pub fn Login(user: LoggedInUserResource) -> impl IntoView {
/> />
<span>Password</span> <span>Password</span>
<i></i> <i></i>
<Show <Show when=move || { show_password() == false }
when=move || {show_password() == false} fallback=move || view! {
fallback=move || view!{ <button on:click=toggle_password class="login-password-visibility"> <button on:click=toggle_password class="login-password-visibility">
<Icon icon=icondata::AiEyeInvisibleFilled /> <Icon icon=icondata::AiEyeInvisibleFilled />
</button> /> } </button>
}
> >
<button on:click=toggle_password class="login-password-visibility"> <button on:click=toggle_password class="login-password-visibility">
<Icon icon=icondata::AiEyeFilled /> <Icon icon=icondata::AiEyeFilled />
</button> </button>
</Show>
</Show>
</div> </div>
<a href="" class="forgot-pw">Forgot Password?</a> <a href="" class="forgot-pw">Forgot Password?</a>
<input type="submit" value="Login" /> <div class="error-msg" >{ move || error_msg.get() }</div>
<Show
when=move || !loading.get()
fallback=move || view! { <Loading /> }
>
<input type="submit" value="Login" />
</Show>
<span class="go-to-signup"> <span class="go-to-signup">
New here? <a href="/signup">Create an Account</a> New here? <a href="/signup">Create an Account</a>
</span> </span>

View File

@ -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 />
} }
}) })
}) })

View File

@ -1,18 +1,22 @@
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; use crate::components::loading::Loading;
#[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());
let (show_password, set_show_password) = create_signal(false); let (show_password, set_show_password) = create_signal(false);
let loading = create_rw_signal(false);
let error_msg = create_rw_signal(None);
let toggle_password = move |_| { let toggle_password = move |_| {
set_show_password.update(|show_password| *show_password = !*show_password); set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password"); log!("showing password");
@ -30,10 +34,16 @@ pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
}; };
log!("new user: {:?}", new_user); log!("new user: {:?}", new_user);
loading.set(true);
error_msg.set(None);
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
log!("Error signing up: {:?}", err); log!("Error signing up: {:?}", err);
error_msg.set(Some(err.to_string()));
// Since we're not sure what the state is, manually refetch the user // Since we're not sure what the state is, manually refetch the user
user.refetch(); user.refetch();
@ -47,6 +57,8 @@ pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
leptos_router::use_navigate()("/", Default::default()); leptos_router::use_navigate()("/", Default::default());
log!("Navigated to home page after signup") log!("Navigated to home page after signup")
} }
loading.set(false);
}); });
}; };
@ -97,7 +109,13 @@ pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
</button> </button>
</Show> </Show>
</div> </div>
<input type="submit" value="Sign Up" /> <div class="error-msg">{ move || error_msg.get() }</div>
<Show
when=move || !loading.get()
fallback=move || view!{ <Loading /> }
>
<input type="submit" value="Sign Up" />
</Show>
<span class="go-to-login"> <span class="go-to-login">
Already Have an Account? <a href="/login" class="link" >Go to Login</a> Already Have an Account? <a href="/login" class="link" >Go to Login</a>
</span> </span>

View File

@ -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,11 +444,11 @@ 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
@ -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>
} }
} }

View File

@ -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);
}; };

View File

@ -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,

View File

@ -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?;

View File

@ -5,3 +5,5 @@ cfg_if! {
pub mod audio; pub mod audio;
} }
} }
pub mod state;

49
src/util/state.rs Normal file
View 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
}
}

80
style/album_page.scss Normal file
View File

@ -0,0 +1,80 @@
@import 'theme.scss';
.album-page-container {
width: 90vw;
.album-header {
height: 40vh;
width: 65vw;
margin: auto;
padding:20px;
background-image: linear-gradient($accent-color, $background-color);
border-radius: 15px;
.album-info {
width: 100%;
height: 100%;
}
}
}
.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;
}
}

View File

@ -8,7 +8,7 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
width: 27rem; width: 27rem;
height: 30rem; height: 31rem;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: $auth-containers; background: $auth-containers;
z-index: 1; z-index: 1;
@ -96,6 +96,17 @@
color: #fff; color: #fff;
transition: all 0.2s; transition: all 0.2s;
} }
.login-form .error-msg {
color: $error-color;
margin-top: 1rem;
height: 1rem;
}
.login-form .loading {
margin-top: 4.5rem;
margin-left: auto;
margin-right: auto;
margin-bottom: calc(1.5rem - 10px);
}
.login-form input[type="submit"] { .login-form input[type="submit"] {
margin-top: 3rem; margin-top: 3rem;
width: 100%; width: 100%;

View File

@ -15,6 +15,7 @@
@import 'song_list.scss'; @import 'song_list.scss';
@import 'profile.scss'; @import 'profile.scss';
@import 'loading.scss'; @import 'loading.scss';
@import 'album_page.scss';
body { body {
font-family: sans-serif; font-family: sans-serif;

View File

@ -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;
}
} }
} }

View File

@ -17,7 +17,7 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
width: 27rem; width: 27rem;
height: 35rem; height: 36rem;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: $auth-containers; background: $auth-containers;
z-index: 1; z-index: 1;
@ -92,7 +92,17 @@
.signup-form .input-box input:focus ~ i { .signup-form .input-box input:focus ~ i {
height: 2.6rem; height: 2.6rem;
} }
.signup-form .error-msg {
color: $error-color;
margin-top: 1rem;
height: 1rem;
}
.signup-form .loading {
margin-top: 4.5rem;
margin-left: auto;
margin-right: auto;
margin-bottom: calc(1.5rem - 10px);
}
.signup-form input[type="submit"] { .signup-form input[type="submit"] {
margin-top: 3.5rem; margin-top: 3.5rem;
width: 100%; width: 100%;

View File

@ -10,7 +10,9 @@ $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;
$error-color: red;
$auth-inputs: #796dd4; $auth-inputs: #796dd4;
$auth-containers: white; $auth-containers: white;