Merge remote-tracking branch 'origin/main' into 95-fix-home-screen-account-button-ui
This commit is contained in:
118
src/components/dashboard_row.rs
Normal file
118
src/components/dashboard_row.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use leptos::html::Ul;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn};
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use leptos_icons::*;
|
||||
|
||||
/// A row of dashboard tiles, with a title
|
||||
pub struct DashboardRow {
|
||||
pub title: String,
|
||||
pub tiles: Vec<Box<dyn DashboardTile>>,
|
||||
}
|
||||
|
||||
impl DashboardRow {
|
||||
pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self {
|
||||
Self {
|
||||
title,
|
||||
tiles,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoView for DashboardRow {
|
||||
fn into_view(self) -> View {
|
||||
let list_ref = create_node_ref::<Ul>();
|
||||
|
||||
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
|
||||
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding
|
||||
|
||||
let scroll_left = move |_| {
|
||||
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||
let client_width = scroll_element.client_width() as f64;
|
||||
let current_pos = scroll_element.scroll_left() as f64;
|
||||
let desired_pos = current_pos - client_width;
|
||||
|
||||
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||
let tile_width = first_tile.client_width() as f64;
|
||||
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
|
||||
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||
} else {
|
||||
warn!("Could not get first tile to scroll left");
|
||||
// Fall back to scrolling by the client width if we can't get the tile width
|
||||
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get scroll element to scroll left");
|
||||
}
|
||||
};
|
||||
|
||||
let scroll_right = move |_| {
|
||||
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||
let client_width = scroll_element.client_width() as f64;
|
||||
let current_pos = scroll_element.scroll_left() as f64;
|
||||
let desired_pos = current_pos + client_width;
|
||||
|
||||
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||
let tile_width = first_tile.client_width() as f64;
|
||||
let scroll_pos = desired_pos - (desired_pos % tile_width);
|
||||
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||
} else {
|
||||
warn!("Could not get first tile to scroll right");
|
||||
// Fall back to scrolling by the client width if we can't get the tile width
|
||||
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get scroll element to scroll right");
|
||||
}
|
||||
};
|
||||
|
||||
let UseElementSizeReturn { width: scroll_element_width, .. } = use_element_size(list_ref);
|
||||
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
|
||||
|
||||
let scroll_right_hidden = Signal::derive(move || {
|
||||
if let Some(scroll_element) = list_ref.get() {
|
||||
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
|
||||
"visibility: hidden"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
let scroll_left_hidden = Signal::derive(move || {
|
||||
if scroll_x.get() <= 0.0 {
|
||||
"visibility: hidden"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="dashboard-tile-row">
|
||||
<div class="dashboard-tile-row-title-row">
|
||||
<h2>{self.title}</h2>
|
||||
<div class="dashboard-tile-row-scroll-btn">
|
||||
<button on:click=scroll_left tabindex=-1 style=scroll_left_hidden>
|
||||
<Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronLeft />
|
||||
</button>
|
||||
<button on:click=scroll_right tabindex=-1 style=scroll_right_hidden>
|
||||
<Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul _ref={list_ref}>
|
||||
{self.tiles.into_iter().map(|tile_info| {
|
||||
view! {
|
||||
<li>
|
||||
{ tile_info.into_view() }
|
||||
</li>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</ul>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
}
|
27
src/components/dashboard_tile.rs
Normal file
27
src/components/dashboard_tile.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
|
||||
pub trait DashboardTile {
|
||||
fn image_path(&self) -> String;
|
||||
fn title(&self) -> String;
|
||||
fn link(&self) -> String;
|
||||
fn description(&self) -> Option<String> { None }
|
||||
}
|
||||
|
||||
impl IntoView for &dyn DashboardTile {
|
||||
fn into_view(self) -> View {
|
||||
let link = self.link();
|
||||
|
||||
view! {
|
||||
<div class="dashboard-tile">
|
||||
<a href={link}>
|
||||
<img src={self.image_path()} alt="dashboard-tile" />
|
||||
<p class="dashboard-tile-title">{self.title()}</p>
|
||||
<p class="dashboard-tile-description">
|
||||
{self.description().unwrap_or_default()}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
}
|
45
src/components/error.rs
Normal file
45
src/components/error.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[component]
|
||||
pub fn ServerError<E: Display + 'static>(
|
||||
#[prop(optional, into, default="An Error Occurred".into())]
|
||||
title: TextProp,
|
||||
#[prop(optional, into)]
|
||||
message: TextProp,
|
||||
#[prop(optional, into)]
|
||||
error: Option<ServerFnError<E>>,
|
||||
) -> impl IntoView {
|
||||
view!{
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<Icon icon=icondata::BiErrorSolid />
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<p>{message}</p>
|
||||
<p>{error.map(|error| format!("{}", error))}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Error<E: Display + 'static>(
|
||||
#[prop(optional, into, default="An Error Occurred".into())]
|
||||
title: TextProp,
|
||||
#[prop(optional, into)]
|
||||
message: TextProp,
|
||||
#[prop(optional, into)]
|
||||
error: Option<E>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<Icon icon=icondata::BiErrorSolid />
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<p>{message}</p>
|
||||
<p>{error.map(|error| format!("{}", error))}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
19
src/components/loading.rs
Normal file
19
src/components/loading.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use leptos::*;
|
||||
|
||||
/// A loading indicator
|
||||
#[component]
|
||||
pub fn Loading() -> impl IntoView {
|
||||
view! {
|
||||
<div class="loading"></div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A full page, centered loading indicator
|
||||
#[component]
|
||||
pub fn LoadingPage() -> impl IntoView {
|
||||
view!{
|
||||
<div class="loading-page">
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
}
|
230
src/components/song_list.rs
Normal file
230
src/components/song_list.rs
Normal file
@ -0,0 +1,230 @@
|
||||
use leptos::*;
|
||||
use leptos::logging::*;
|
||||
use leptos_icons::*;
|
||||
|
||||
use crate::api::songs::*;
|
||||
use crate::songdata::SongData;
|
||||
use crate::models::{Album, Artist};
|
||||
|
||||
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
||||
|
||||
#[component]
|
||||
pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView {
|
||||
view! {
|
||||
<table class="song-list">
|
||||
{
|
||||
songs.with(|songs| {
|
||||
let mut first_song = true;
|
||||
|
||||
songs.iter().map(|song| {
|
||||
let playing = first_song.into();
|
||||
first_song = false;
|
||||
|
||||
let extra = Option::<()>::None;
|
||||
|
||||
view! {
|
||||
<SongListItem song={song.clone()} song_playing=playing extra />
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SongListExtra<T>(songs: MaybeSignal<Vec<(SongData, T)>>) -> impl IntoView where
|
||||
T: Clone + IntoView + 'static
|
||||
{
|
||||
view! {
|
||||
<table class="song-list">
|
||||
{
|
||||
songs.with(|songs| {
|
||||
let mut first_song = true;
|
||||
|
||||
songs.iter().map(|(song, extra)| {
|
||||
let playing = first_song.into();
|
||||
first_song = false;
|
||||
|
||||
view! {
|
||||
<SongListItem song={song.clone()} song_playing=playing extra=Some(extra.clone()) />
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>) -> impl IntoView where
|
||||
T: IntoView + 'static
|
||||
{
|
||||
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
||||
let disliked = create_rw_signal(song.like_dislike.map(|(_, disliked)| disliked).unwrap_or(false));
|
||||
|
||||
view! {
|
||||
<tr class="song-list-item">
|
||||
<td class="song-image"><SongImage image_path=song.image_path song_playing /></td>
|
||||
<td class="song-title"><p>{song.title}</p></td>
|
||||
<td class="song-list-spacer"></td>
|
||||
<td class="song-artists"><SongArtists artists=song.artists /></td>
|
||||
<td class="song-list-spacer"></td>
|
||||
<td class="song-album"><SongAlbum album=song.album /></td>
|
||||
<td class="song-list-spacer-big"></td>
|
||||
<td class="song-like-dislike"><SongLikeDislike song_id=song.id liked disliked/></td>
|
||||
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
|
||||
{extra.map(|extra| view! {
|
||||
<td class="song-list-spacer"></td>
|
||||
<td>{extra}</td>
|
||||
})}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
/// Display the song's image, with an overlay if the song is playing
|
||||
/// When the song list item is hovered, the overlay will show the play button
|
||||
#[component]
|
||||
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>) -> impl IntoView {
|
||||
view! {
|
||||
<img class="song-image" src={image_path}/>
|
||||
{if song_playing.get() {
|
||||
view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view()
|
||||
} else {
|
||||
view! { <Icon class="song-image-overlay hide-until-hover" icon=icondata::BsPlayFill /> }.into_view()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays a song's artists, with links to their artist pages
|
||||
#[component]
|
||||
fn SongArtists(artists: Vec<Artist>) -> impl IntoView {
|
||||
let num_artists = artists.len() as isize;
|
||||
|
||||
artists.iter().enumerate().map(|(i, artist)| {
|
||||
let i = i as isize;
|
||||
|
||||
view! {
|
||||
{
|
||||
if let Some(id) = artist.id {
|
||||
view! { <a href={format!("/artist/{}", id)}>{artist.name.clone()}</a> }.into_view()
|
||||
} else {
|
||||
view! { <span>{artist.name.clone()}</span> }.into_view()
|
||||
}
|
||||
}
|
||||
{if i < num_artists - 2 { ", " } else if i == num_artists - 2 { " & " } else { "" }}
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Display a song's album, with a link to the album page
|
||||
#[component]
|
||||
fn SongAlbum(album: Option<Album>) -> impl IntoView {
|
||||
album.as_ref().map(|album| {
|
||||
view! {
|
||||
<span>
|
||||
{
|
||||
if let Some(id) = album.id {
|
||||
view! { <a href={format!("/album/{}", id)}>{album.title.clone()}</a> }.into_view()
|
||||
} else {
|
||||
view! { <span>{album.title.clone()}</span> }.into_view()
|
||||
}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
|
||||
#[component]
|
||||
fn SongLikeDislike(
|
||||
#[prop(into)]
|
||||
song_id: MaybeSignal<i32>,
|
||||
liked: RwSignal<bool>,
|
||||
disliked: RwSignal<bool>) -> impl IntoView
|
||||
{
|
||||
let like_icon = Signal::derive(move || {
|
||||
if liked.get() {
|
||||
icondata::TbThumbUpFilled
|
||||
} else {
|
||||
icondata::TbThumbUp
|
||||
}
|
||||
});
|
||||
|
||||
let dislike_icon = Signal::derive(move || {
|
||||
if disliked.get() {
|
||||
icondata::TbThumbDownFilled
|
||||
} else {
|
||||
icondata::TbThumbDown
|
||||
}
|
||||
});
|
||||
|
||||
let like_class = MaybeProp::derive(move || {
|
||||
if liked.get() {
|
||||
Some(TextProp::from("controlbtn"))
|
||||
} else {
|
||||
Some(TextProp::from("controlbtn hide-until-hover"))
|
||||
}
|
||||
});
|
||||
|
||||
let dislike_class = MaybeProp::derive(move || {
|
||||
if disliked.get() {
|
||||
Some(TextProp::from("controlbtn hmirror"))
|
||||
} else {
|
||||
Some(TextProp::from("controlbtn hmirror hide-until-hover"))
|
||||
}
|
||||
});
|
||||
|
||||
// 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 new_liked = !liked.get_untracked();
|
||||
liked.set(new_liked);
|
||||
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 |_| {
|
||||
disliked.set(!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! {
|
||||
<button on:click=toggle_dislike>
|
||||
<Icon class=dislike_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=dislike_icon />
|
||||
</button>
|
||||
<button on:click=toggle_like>
|
||||
<Icon class=like_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=like_icon />
|
||||
</button>
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user