Add playlist page
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 41s
Push Workflows / clippy (push) Successful in 37s
Push Workflows / leptos-test (push) Successful in 1m18s
Push Workflows / test (push) Successful in 1m39s
Push Workflows / build (push) Successful in 2m43s
Push Workflows / docker-build (push) Failing after 9m22s
Push Workflows / nix-build (push) Successful in 12m39s
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 41s
Push Workflows / clippy (push) Successful in 37s
Push Workflows / leptos-test (push) Successful in 1m18s
Push Workflows / test (push) Successful in 1m39s
Push Workflows / build (push) Successful in 2m43s
Push Workflows / docker-build (push) Failing after 9m22s
Push Workflows / nix-build (push) Successful in 12m39s
This commit is contained in:
@ -6,6 +6,7 @@ use crate::pages::album::*;
|
||||
use crate::pages::artist::*;
|
||||
use crate::pages::dashboard::*;
|
||||
use crate::pages::login::*;
|
||||
use crate::pages::playlist::*;
|
||||
use crate::pages::profile::*;
|
||||
use crate::pages::search::*;
|
||||
use crate::pages::signup::*;
|
||||
@ -73,6 +74,7 @@ pub fn App() -> impl IntoView {
|
||||
<Route path=path!("album/:id") view=AlbumPage />
|
||||
<Route path=path!("artist/:id") view=ArtistPage />
|
||||
<Route path=path!("song/:id") view=SongPage />
|
||||
<Route path=path!("playlist/:id") view=PlaylistPage />
|
||||
</ParentRoute>
|
||||
<Route path=path!("/login") view=Login />
|
||||
<Route path=path!("/signup") view=Signup />
|
||||
|
@ -2,6 +2,7 @@ pub mod album;
|
||||
pub mod artist;
|
||||
pub mod dashboard;
|
||||
pub mod login;
|
||||
pub mod playlist;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod signup;
|
||||
|
218
src/pages/playlist.rs
Normal file
218
src/pages/playlist.rs
Normal file
@ -0,0 +1,218 @@
|
||||
use crate::api::playlists::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
use crate::models::backend;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::either::*;
|
||||
use leptos::ev::{keydown, KeyboardEvent};
|
||||
use leptos::html::{Button, Input};
|
||||
use leptos::logging::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use leptos_router::components::Form;
|
||||
use leptos_router::hooks::{use_navigate, use_params_map};
|
||||
use leptos_use::{on_click_outside, use_event_listener};
|
||||
use std::sync::Arc;
|
||||
use web_sys::Response;
|
||||
|
||||
#[component]
|
||||
pub fn PlaylistPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
|
||||
view! {
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
Either::Left(view! { <PlaylistIdPage id /> })
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Invalid Playlist ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
},
|
||||
None => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="No Playlist ID"
|
||||
message="You must specify a playlist ID to view its page."
|
||||
/>
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PlaylistIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let playlist_songs = Resource::new(id, get_playlist_songs);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || GlobalState::playlists().get().map(|playlists| {
|
||||
let playlist = playlists.map(|playlists| {
|
||||
playlists.into_iter().find(|playlist| playlist.id == id.get())
|
||||
});
|
||||
|
||||
match playlist {
|
||||
Ok(Some(playlist)) => {
|
||||
Either::Left(view! { <PlaylistInfo playlist /> })
|
||||
},
|
||||
Ok(None) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Playlist not found"
|
||||
message="The playlist you are looking for does not exist."
|
||||
/>
|
||||
})
|
||||
}
|
||||
Err(e) => Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Error loading playlist"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}),
|
||||
}
|
||||
})}
|
||||
{move || playlist_songs.get().map(|playlist_songs| {
|
||||
match playlist_songs {
|
||||
Ok(playlist_songs) => {
|
||||
Either::Left(view! { <SongList songs=playlist_songs /> })
|
||||
},
|
||||
Err(e) => Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Error loading playlist songs"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PlaylistInfo(playlist: backend::Playlist) -> impl IntoView {
|
||||
let on_img_edit_response = Arc::new(move |response: &Response| {
|
||||
if response.ok() {
|
||||
// TODO inform browser that image has changed
|
||||
} else {
|
||||
error!("Error editing playlist image: {}", response.status());
|
||||
// TODO toast
|
||||
}
|
||||
});
|
||||
|
||||
let playing = RwSignal::new(false);
|
||||
|
||||
let editing_name = RwSignal::new(false);
|
||||
let playlist_name = RwSignal::new(playlist.name.clone());
|
||||
|
||||
let name_edit_input = NodeRef::<Input>::new();
|
||||
|
||||
let edit_complete = move || {
|
||||
editing_name.set(false);
|
||||
|
||||
spawn_local(async move {
|
||||
if let Err(e) = rename_playlist(playlist.id, playlist_name.get_untracked()).await {
|
||||
error!("Error editing playlist name: {}", e);
|
||||
// TODO toast
|
||||
} else {
|
||||
GlobalState::playlists().refetch();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let _edit_close_handler = on_click_outside(name_edit_input, move |_| edit_complete());
|
||||
|
||||
let _edit_enter_handler =
|
||||
use_event_listener(name_edit_input, keydown, move |event: KeyboardEvent| {
|
||||
if event.key() == "Enter" {
|
||||
event.prevent_default();
|
||||
edit_complete();
|
||||
}
|
||||
});
|
||||
|
||||
let on_play = move |_| {
|
||||
playing.set(!playing.get());
|
||||
};
|
||||
|
||||
let delete_btn = NodeRef::<Button>::new();
|
||||
|
||||
let confirm_delete = RwSignal::new(false);
|
||||
|
||||
let on_delete = move |_| {
|
||||
if confirm_delete.get_untracked() {
|
||||
spawn_local(async move {
|
||||
if let Err(e) = delete_playlist(playlist.id).await {
|
||||
error!("Error deleting playlist: {}", e);
|
||||
} else {
|
||||
GlobalState::playlists().refetch();
|
||||
use_navigate()("/", Default::default());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
confirm_delete.set(true);
|
||||
}
|
||||
};
|
||||
|
||||
let _delete_escape_handler = on_click_outside(delete_btn, move |_| {
|
||||
confirm_delete.set(false);
|
||||
});
|
||||
|
||||
let on_edit = move |_| {
|
||||
editing_name.set(true);
|
||||
|
||||
name_edit_input.on_load(move |input| {
|
||||
input.select();
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex items-center">
|
||||
<div class="group relative">
|
||||
<img class="w-70 h-70 p-5 rounded-4xl group-hover:brightness-45 transition-all"
|
||||
src={format!("/assets/images/playlist/{}.webp", playlist.id)} onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
|
||||
<Form action="/api/playlists/edit_image" method="POST" enctype="multipart/form-data".to_string() on_response=on_img_edit_response.clone()>
|
||||
<input type="hidden" name="id" value={playlist.id} />
|
||||
<label for="edit-playlist-img">
|
||||
<Icon icon={icondata::BiPencilSolid} {..} class="absolute bottom-10 right-10 w-8 h-8 control opacity-0 group-hover:opacity-100" />
|
||||
</label>
|
||||
<input id="edit-playlist-img" type="file" accept="image/*" class="hidden" onchange="form.submit()" />
|
||||
</Form>
|
||||
</div>
|
||||
<div>
|
||||
<Show
|
||||
when=move || editing_name.get()
|
||||
fallback=move || view! {
|
||||
<h1 class="text-4xl" on:click=on_edit>{playlist_name}</h1>
|
||||
}
|
||||
>
|
||||
<input type="text" bind:value=playlist_name
|
||||
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 w-full outline-none text-4xl"
|
||||
required
|
||||
node_ref=name_edit_input autocomplete="off" />
|
||||
</Show>
|
||||
<p>{format!("Last Updated {}", playlist.updated_at.format("%B %-d %Y"))}</p>
|
||||
<div class="flex">
|
||||
<button class="control" on:click=on_play>
|
||||
{move || if playing.get() {
|
||||
Either::Left(view! { <Icon icon={icondata::BsPauseFill} {..} class="w-12 h-12" /> })
|
||||
} else {
|
||||
Either::Right(view! { <Icon icon={icondata::BsPlayFill} {..} class="w-12 h-12" /> })
|
||||
}}
|
||||
</button>
|
||||
<button node_ref=delete_btn class="control" on:click=on_delete style={move || if confirm_delete.get() { "color: red;" } else { "" }} >
|
||||
<Icon icon={icondata::AiDeleteOutlined} {..} class="w-12 h-12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user