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

This commit is contained in:
2025-05-06 01:34:53 +00:00
parent c17aeb3822
commit 4d1859b331
3 changed files with 221 additions and 0 deletions

View File

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

View File

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