Merge branch '106-create-song-list-component' into 42-create-profile-page
This commit is contained in:
39
src/albumdata.rs
Normal file
39
src/albumdata.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use crate::models::Artist;
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
|
||||
use time::Date;
|
||||
|
||||
/// Holds information about an album
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
pub struct AlbumData {
|
||||
/// Album id
|
||||
pub id: i32,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artists
|
||||
pub artists: Vec<Artist>,
|
||||
/// Album release date
|
||||
pub release_date: Option<Date>,
|
||||
/// Path to album image, relative to the root of the web server.
|
||||
/// For example, `"/assets/images/Album.jpg"`
|
||||
pub image_path: String,
|
||||
}
|
||||
|
||||
impl DashboardTile for AlbumData {
|
||||
fn image_path(&self) -> String {
|
||||
self.image_path.clone()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn link(&self) -> String {
|
||||
format!("/album/{}", self.id)
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
Some(format!("Album • {}", Artist::display_list(&self.artists)))
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
use crate::playbar::PlayBar;
|
||||
use crate::playbar::CustomTitle;
|
||||
use crate::playstatus::PlayStatus;
|
||||
use crate::queue::Queue;
|
||||
use leptos::*;
|
||||
@ -24,7 +25,7 @@ pub fn App() -> impl IntoView {
|
||||
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="LibreTunes"/>
|
||||
<CustomTitle play_status=play_status/>
|
||||
|
||||
// content for this welcome page
|
||||
<Router fallback=|| {
|
||||
|
32
src/artistdata.rs
Normal file
32
src/artistdata.rs
Normal file
@ -0,0 +1,32 @@
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
|
||||
/// Holds information about an artist
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
pub struct ArtistData {
|
||||
/// Artist id
|
||||
pub id: i32,
|
||||
/// Artist name
|
||||
pub name: String,
|
||||
/// Path to artist image, relative to the root of the web server.
|
||||
/// For example, `"/assets/images/Artist.jpg"`
|
||||
pub image_path: String,
|
||||
}
|
||||
|
||||
impl DashboardTile for ArtistData {
|
||||
fn image_path(&self) -> String {
|
||||
self.image_path.clone()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn link(&self) -> String {
|
||||
format!("/artist/{}", self.id)
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
Some("Artist".to_string())
|
||||
}
|
||||
}
|
@ -2,4 +2,7 @@ pub mod sidebar;
|
||||
pub mod dashboard;
|
||||
pub mod search;
|
||||
pub mod personal;
|
||||
pub mod dashboard_tile;
|
||||
pub mod dashboard_row;
|
||||
pub mod upload;
|
||||
pub mod song_list;
|
||||
|
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()
|
||||
}
|
||||
}
|
157
src/components/song_list.rs
Normal file
157
src/components/song_list.rs
Normal file
@ -0,0 +1,157 @@
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
|
||||
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;
|
||||
|
||||
view! {
|
||||
<SongListItem song={song.clone()} song_playing=playing />
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SongListItem(song: SongData, song_playing: MaybeSignal<bool>) -> impl IntoView {
|
||||
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 liked disliked/></td>
|
||||
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</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(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"))
|
||||
}
|
||||
});
|
||||
|
||||
let toggle_like = move |_| {
|
||||
liked.set(!liked.get_untracked());
|
||||
disliked.set(disliked.get_untracked() && !liked.get_untracked());
|
||||
};
|
||||
|
||||
let toggle_dislike = move |_| {
|
||||
disliked.set(!disliked.get_untracked());
|
||||
liked.set(liked.get_untracked() && !disliked.get_untracked());
|
||||
};
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod songdata;
|
||||
pub mod albumdata;
|
||||
pub mod artistdata;
|
||||
pub mod playstatus;
|
||||
pub mod playbar;
|
||||
pub mod database;
|
||||
|
@ -5,6 +5,7 @@ use crate::api::songs;
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::html::{Audio, Div};
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos_meta::Title;
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::{utils::Pausable, use_interval_fn};
|
||||
@ -460,6 +461,21 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the title of the page based on the currently playing song
|
||||
#[component]
|
||||
pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let title = create_memo(move |_| {
|
||||
play_status.with(|play_status| {
|
||||
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| {
|
||||
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
|
||||
})
|
||||
})
|
||||
});
|
||||
view! {
|
||||
<Title text=title />
|
||||
}
|
||||
}
|
||||
|
||||
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
||||
#[component]
|
||||
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
|
@ -1,4 +1,5 @@
|
||||
use crate::models::{Album, Artist, Song};
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use time::Date;
|
||||
@ -6,7 +7,7 @@ use time::Date;
|
||||
/// Holds information about a song
|
||||
///
|
||||
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct SongData {
|
||||
/// Song id
|
||||
pub id: i32,
|
||||
@ -62,3 +63,21 @@ impl TryInto<Song> for SongData {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DashboardTile for SongData {
|
||||
fn image_path(&self) -> String {
|
||||
self.image_path.clone()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn link(&self) -> String {
|
||||
format!("/song/{}", self.id)
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
Some(format!("Song • {}", Artist::display_list(&self.artists)))
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user