Compare commits
1 Commits
50a0ddcdbc
...
55607df07b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55607df07b |
1165
frontend/Cargo.lock
generated
Normal file
1165
frontend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
frontend/Cargo.toml
Normal file
17
frontend/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "shanty-frontend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
yew = { version = "0.21", features = ["csr"] }
|
||||||
|
yew-router = "0.18"
|
||||||
|
gloo-net = "0.6"
|
||||||
|
gloo-timers = "0.3"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window"] }
|
||||||
2
frontend/Trunk.toml
Normal file
2
frontend/Trunk.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[build]
|
||||||
|
dist = "../static"
|
||||||
11
frontend/index.html
Normal file
11
frontend/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shanty</title>
|
||||||
|
<link data-trunk rel="css" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
185
frontend/src/api.rs
Normal file
185
frontend/src/api.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
use gloo_net::http::Request;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
const BASE: &str = "/api";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiError(pub String);
|
||||||
|
|
||||||
|
async fn get_json<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
|
||||||
|
let resp = Request::get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_json<T: DeserializeOwned>(url: &str, body: &str) -> Result<T, ApiError> {
|
||||||
|
let resp = Request::post(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_empty<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
|
||||||
|
let resp = Request::post(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(url: &str) -> Result<(), ApiError> {
|
||||||
|
let resp = Request::delete(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status ---
|
||||||
|
pub async fn get_status() -> Result<Status, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/status")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
pub async fn search_artist(query: &str, limit: u32) -> Result<Vec<ArtistResult>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/search/artist?q={query}&limit={limit}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_album(query: &str, artist: Option<&str>, limit: u32) -> Result<Vec<AlbumResult>, ApiError> {
|
||||||
|
let mut url = format!("{BASE}/search/album?q={query}&limit={limit}");
|
||||||
|
if let Some(a) = artist {
|
||||||
|
url.push_str(&format!("&artist={a}"));
|
||||||
|
}
|
||||||
|
get_json(&url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_track(query: &str, artist: Option<&str>, limit: u32) -> Result<Vec<TrackResult>, ApiError> {
|
||||||
|
let mut url = format!("{BASE}/search/track?q={query}&limit={limit}");
|
||||||
|
if let Some(a) = artist {
|
||||||
|
url.push_str(&format!("&artist={a}"));
|
||||||
|
}
|
||||||
|
get_json(&url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Library ---
|
||||||
|
pub async fn list_artists(limit: u64, offset: u64) -> Result<Vec<Artist>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/artists?limit={limit}&offset={offset}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_artist(id: i32) -> Result<ArtistDetail, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/artists/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_album(id: i32) -> Result<AlbumDetail, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/albums/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tracks(limit: u64, offset: u64) -> Result<Vec<Track>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/tracks?limit={limit}&offset={offset}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Watchlist ---
|
||||||
|
pub async fn add_artist(name: &str, mbid: Option<&str>) -> Result<AddSummary, ApiError> {
|
||||||
|
let body = match mbid {
|
||||||
|
Some(m) => format!(r#"{{"name":"{name}","mbid":"{m}"}}"#),
|
||||||
|
None => format!(r#"{{"name":"{name}"}}"#),
|
||||||
|
};
|
||||||
|
post_json(&format!("{BASE}/artists"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_album(artist: &str, album: &str, mbid: Option<&str>) -> Result<AddSummary, ApiError> {
|
||||||
|
let body = match mbid {
|
||||||
|
Some(m) => format!(r#"{{"artist":"{artist}","album":"{album}","mbid":"{m}"}}"#),
|
||||||
|
None => format!(r#"{{"artist":"{artist}","album":"{album}"}}"#),
|
||||||
|
};
|
||||||
|
post_json(&format!("{BASE}/albums"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_watchlist() -> Result<Vec<WatchListEntry>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/watchlist")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_watchlist(id: i32) -> Result<(), ApiError> {
|
||||||
|
delete(&format!("{BASE}/watchlist/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Downloads ---
|
||||||
|
pub async fn get_downloads(status: Option<&str>) -> Result<Vec<DownloadItem>, ApiError> {
|
||||||
|
let mut url = format!("{BASE}/downloads/queue");
|
||||||
|
if let Some(s) = status {
|
||||||
|
url.push_str(&format!("?status={s}"));
|
||||||
|
}
|
||||||
|
get_json(&url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enqueue_download(query: &str) -> Result<DownloadItem, ApiError> {
|
||||||
|
post_json(
|
||||||
|
&format!("{BASE}/downloads"),
|
||||||
|
&format!(r#"{{"query":"{query}"}}"#),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_downloads() -> Result<SyncStats, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/downloads/sync")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_downloads() -> Result<TaskRef, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/downloads/process")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn retry_download(id: i32) -> Result<(), ApiError> {
|
||||||
|
let resp = Request::post(&format!("{BASE}/downloads/retry/{id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_download(id: i32) -> Result<(), ApiError> {
|
||||||
|
delete(&format!("{BASE}/downloads/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- System ---
|
||||||
|
pub async fn trigger_index() -> Result<TaskRef, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/index")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_tag() -> Result<TaskRef, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/tag")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_organize() -> Result<TaskRef, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/organize")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_task(id: &str) -> Result<TaskInfo, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/tasks/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_config() -> Result<AppConfig, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/config")).await
|
||||||
|
}
|
||||||
2
frontend/src/components/mod.rs
Normal file
2
frontend/src/components/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod navbar;
|
||||||
|
pub mod status_badge;
|
||||||
30
frontend/src/components/navbar.rs
Normal file
30
frontend/src/components/navbar.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use crate::pages::Route;
|
||||||
|
|
||||||
|
#[function_component(Navbar)]
|
||||||
|
pub fn navbar() -> Html {
|
||||||
|
let route = use_route::<Route>();
|
||||||
|
|
||||||
|
let link = |to: Route, label: &str| {
|
||||||
|
let active = route.as_ref() == Some(&to);
|
||||||
|
let class = if active { "active" } else { "" };
|
||||||
|
html! {
|
||||||
|
<Link<Route> to={to} classes={classes!(class)}>{ label }</Link<Route>>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="sidebar">
|
||||||
|
<h1>{ "Shanty" }</h1>
|
||||||
|
<nav>
|
||||||
|
{ link(Route::Dashboard, "Dashboard") }
|
||||||
|
{ link(Route::Search, "Search") }
|
||||||
|
{ link(Route::Library, "Library") }
|
||||||
|
{ link(Route::Downloads, "Downloads") }
|
||||||
|
{ link(Route::Settings, "Settings") }
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/components/status_badge.rs
Normal file
26
frontend/src/components/status_badge.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(StatusBadge)]
|
||||||
|
pub fn status_badge(props: &Props) -> Html {
|
||||||
|
let class = match props.status.to_lowercase().as_str() {
|
||||||
|
"wanted" => "badge badge-wanted",
|
||||||
|
"available" => "badge badge-available",
|
||||||
|
"downloaded" => "badge badge-downloaded",
|
||||||
|
"owned" => "badge badge-owned",
|
||||||
|
"pending" => "badge badge-pending",
|
||||||
|
"failed" => "badge badge-failed",
|
||||||
|
"completed" => "badge badge-completed",
|
||||||
|
"downloading" => "badge badge-available",
|
||||||
|
"cancelled" => "badge badge-pending",
|
||||||
|
_ => "badge",
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<span class={class}>{ &props.status }</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/main.rs
Normal file
28
frontend/src/main.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
mod api;
|
||||||
|
mod components;
|
||||||
|
mod pages;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use components::navbar::Navbar;
|
||||||
|
use pages::{switch, Route};
|
||||||
|
|
||||||
|
#[function_component(App)]
|
||||||
|
fn app() -> Html {
|
||||||
|
html! {
|
||||||
|
<BrowserRouter>
|
||||||
|
<div class="app">
|
||||||
|
<Navbar />
|
||||||
|
<div class="main-content">
|
||||||
|
<Switch<Route> render={switch} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
yew::Renderer::<App>::new().render();
|
||||||
|
}
|
||||||
71
frontend/src/pages/album.rs
Normal file
71
frontend/src/pages/album.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::types::AlbumDetail;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AlbumPage)]
|
||||||
|
pub fn album_page(props: &Props) -> Html {
|
||||||
|
let detail = use_state(|| None::<AlbumDetail>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let id = props.id;
|
||||||
|
|
||||||
|
{
|
||||||
|
let detail = detail.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
use_effect_with(id, move |id| {
|
||||||
|
let id = *id;
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_album(id).await {
|
||||||
|
Ok(d) => detail.set(Some(d)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
return html! { <div class="error">{ format!("Error: {err}") }</div> };
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref d) = *detail else {
|
||||||
|
return html! { <p class="loading">{ "Loading..." }</p> };
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ &d.album.name }</h2>
|
||||||
|
<p class="text-muted">{ format!("by {}", d.album.album_artist) }</p>
|
||||||
|
if let Some(year) = d.album.year {
|
||||||
|
<p class="text-muted text-sm">{ format!("Year: {year}") }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-1">{ format!("Tracks ({})", d.tracks.len()) }</h3>
|
||||||
|
if d.tracks.is_empty() {
|
||||||
|
<p class="text-muted">{ "No tracks found." }</p>
|
||||||
|
} else {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "#" }</th><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Codec" }</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for d.tracks.iter().map(|t| html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ t.track_number.map(|n| n.to_string()).unwrap_or_default() }</td>
|
||||||
|
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td>{ t.artist.as_deref().unwrap_or("") }</td>
|
||||||
|
<td class="text-muted text-sm">{ t.codec.as_deref().unwrap_or("") }</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend/src/pages/artist.rs
Normal file
75
frontend/src/pages/artist.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::pages::Route;
|
||||||
|
use crate::types::ArtistDetail;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ArtistPage)]
|
||||||
|
pub fn artist_page(props: &Props) -> Html {
|
||||||
|
let detail = use_state(|| None::<ArtistDetail>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let id = props.id;
|
||||||
|
|
||||||
|
{
|
||||||
|
let detail = detail.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
use_effect_with(id, move |id| {
|
||||||
|
let id = *id;
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_artist(id).await {
|
||||||
|
Ok(d) => detail.set(Some(d)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
return html! { <div class="error">{ format!("Error: {err}") }</div> };
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref d) = *detail else {
|
||||||
|
return html! { <p class="loading">{ "Loading..." }</p> };
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ &d.artist.name }</h2>
|
||||||
|
if let Some(ref mbid) = d.artist.musicbrainz_id {
|
||||||
|
<p class="text-muted text-sm">{ format!("MBID: {mbid}") }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-1">{ format!("Albums ({})", d.albums.len()) }</h3>
|
||||||
|
if d.albums.is_empty() {
|
||||||
|
<p class="text-muted">{ "No albums found." }</p>
|
||||||
|
} else {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Name" }</th><th>{ "Year" }</th><th>{ "Genre" }</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for d.albums.iter().map(|a| html! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Link<Route> to={Route::Album { id: a.id }}>
|
||||||
|
{ &a.name }
|
||||||
|
</Link<Route>>
|
||||||
|
</td>
|
||||||
|
<td>{ a.year.map(|y| y.to_string()).unwrap_or_default() }</td>
|
||||||
|
<td>{ a.genre.as_deref().unwrap_or("") }</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend/src/pages/dashboard.rs
Normal file
88
frontend/src/pages/dashboard.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::components::status_badge::StatusBadge;
|
||||||
|
use crate::types::Status;
|
||||||
|
|
||||||
|
#[function_component(DashboardPage)]
|
||||||
|
pub fn dashboard() -> Html {
|
||||||
|
let status = use_state(|| None::<Status>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
{
|
||||||
|
let status = status.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_status().await {
|
||||||
|
Ok(s) => status.set(Some(s)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
return html! { <div class="error">{ format!("Error: {err}") }</div> };
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref s) = *status else {
|
||||||
|
return html! { <p class="loading">{ "Loading..." }</p> };
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Dashboard" }</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{ s.library.total_items }</div>
|
||||||
|
<div class="label">{ "Total Items" }</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{ s.library.wanted }</div>
|
||||||
|
<div class="label">{ "Wanted" }</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{ s.library.downloaded }</div>
|
||||||
|
<div class="label">{ "Downloaded" }</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{ s.library.owned }</div>
|
||||||
|
<div class="label">{ "Owned" }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Download Queue" }</h3>
|
||||||
|
<p>{ format!("{} pending, {} downloading", s.queue.pending, s.queue.downloading) }</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if !s.tasks.is_empty() {
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Background Tasks" }</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{ "Type" }</th>
|
||||||
|
<th>{ "Status" }</th>
|
||||||
|
<th>{ "Result" }</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for s.tasks.iter().map(|t| html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ &t.task_type }</td>
|
||||||
|
<td><StatusBadge status={t.status.clone()} /></td>
|
||||||
|
<td class="text-sm text-muted">{ t.result.as_deref().unwrap_or("") }</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
191
frontend/src/pages/downloads.rs
Normal file
191
frontend/src/pages/downloads.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::components::status_badge::StatusBadge;
|
||||||
|
use crate::types::DownloadItem;
|
||||||
|
|
||||||
|
#[function_component(DownloadsPage)]
|
||||||
|
pub fn downloads_page() -> Html {
|
||||||
|
let items = use_state(|| None::<Vec<DownloadItem>>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let message = use_state(|| None::<String>);
|
||||||
|
let dl_query = use_state(|| String::new());
|
||||||
|
|
||||||
|
let refresh = {
|
||||||
|
let items = items.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: ()| {
|
||||||
|
let items = items.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_downloads(None).await {
|
||||||
|
Ok(d) => items.set(Some(d)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
refresh.emit(());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_sync = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::sync_downloads().await {
|
||||||
|
Ok(s) => {
|
||||||
|
message.set(Some(format!(
|
||||||
|
"Synced: {} found, {} enqueued, {} skipped",
|
||||||
|
s.found, s.enqueued, s.skipped
|
||||||
|
)));
|
||||||
|
refresh.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_process = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::process_downloads().await {
|
||||||
|
Ok(t) => message.set(Some(format!("Processing started (task: {})", t.task_id))),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_manual_dl = {
|
||||||
|
let dl_query = dl_query.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
let q = (*dl_query).clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::enqueue_download(&q).await {
|
||||||
|
Ok(item) => {
|
||||||
|
message.set(Some(format!("Enqueued: {}", item.query)));
|
||||||
|
refresh.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Downloads" }</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions mb-2">
|
||||||
|
<button class="btn btn-primary" onclick={on_sync}>{ "Sync from Watchlist" }</button>
|
||||||
|
<button class="btn btn-success" onclick={on_process}>{ "Process Queue" }</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={on_manual_dl}>
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Manual download (search query or URL)..."
|
||||||
|
value={(*dl_query).clone()}
|
||||||
|
oninput={let q = dl_query.clone(); Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
q.set(input.value());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">{ "Download" }</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
if let Some(ref msg) = *message {
|
||||||
|
<div class="card" style="border-color: var(--success);">{ msg }</div>
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
<div class="card error">{ err }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{ match &*items {
|
||||||
|
None => html! { <p class="loading">{ "Loading..." }</p> },
|
||||||
|
Some(items) if items.is_empty() => html! { <p class="text-muted">{ "Queue is empty." }</p> },
|
||||||
|
Some(items) => html! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "ID" }</th><th>{ "Query" }</th><th>{ "Status" }</th><th>{ "Retries" }</th><th>{ "Error" }</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for items.iter().map(|item| {
|
||||||
|
let id = item.id;
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ item.id }</td>
|
||||||
|
<td>{ &item.query }</td>
|
||||||
|
<td><StatusBadge status={item.status.clone()} /></td>
|
||||||
|
<td>{ item.retry_count }</td>
|
||||||
|
<td class="text-sm text-muted">{ item.error_message.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>
|
||||||
|
if item.status == "Failed" {
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
onclick={{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::retry_download(id).await;
|
||||||
|
refresh.emit(());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{ "Retry" }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
if item.status == "Pending" {
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
onclick={{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::cancel_download(id).await;
|
||||||
|
refresh.emit(());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{ "Cancel" }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
64
frontend/src/pages/library.rs
Normal file
64
frontend/src/pages/library.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::pages::Route;
|
||||||
|
use crate::types::Artist;
|
||||||
|
|
||||||
|
#[function_component(LibraryPage)]
|
||||||
|
pub fn library_page() -> Html {
|
||||||
|
let artists = use_state(|| None::<Vec<Artist>>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
{
|
||||||
|
let artists = artists.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::list_artists(100, 0).await {
|
||||||
|
Ok(a) => artists.set(Some(a)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
return html! { <div class="error">{ format!("Error: {err}") }</div> };
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref artists) = *artists else {
|
||||||
|
return html! { <p class="loading">{ "Loading..." }</p> };
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Library" }</h2>
|
||||||
|
<p>{ format!("{} artists", artists.len()) }</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if artists.is_empty() {
|
||||||
|
<p class="text-muted">{ "No artists in library. Use Search to add some!" }</p>
|
||||||
|
} else {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Name" }</th><th>{ "MusicBrainz ID" }</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for artists.iter().map(|a| html! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Link<Route> to={Route::Artist { id: a.id }}>
|
||||||
|
{ &a.name }
|
||||||
|
</Link<Route>>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted text-sm">{ a.musicbrainz_id.as_deref().unwrap_or("") }</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/src/pages/mod.rs
Normal file
44
frontend/src/pages/mod.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
pub mod album;
|
||||||
|
pub mod artist;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod downloads;
|
||||||
|
pub mod library;
|
||||||
|
pub mod search;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
|
pub enum Route {
|
||||||
|
#[at("/")]
|
||||||
|
Dashboard,
|
||||||
|
#[at("/search")]
|
||||||
|
Search,
|
||||||
|
#[at("/library")]
|
||||||
|
Library,
|
||||||
|
#[at("/artists/:id")]
|
||||||
|
Artist { id: i32 },
|
||||||
|
#[at("/albums/:id")]
|
||||||
|
Album { id: i32 },
|
||||||
|
#[at("/downloads")]
|
||||||
|
Downloads,
|
||||||
|
#[at("/settings")]
|
||||||
|
Settings,
|
||||||
|
#[not_found]
|
||||||
|
#[at("/404")]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch(route: Route) -> Html {
|
||||||
|
match route {
|
||||||
|
Route::Dashboard => html! { <dashboard::DashboardPage /> },
|
||||||
|
Route::Search => html! { <search::SearchPage /> },
|
||||||
|
Route::Library => html! { <library::LibraryPage /> },
|
||||||
|
Route::Artist { id } => html! { <artist::ArtistPage {id} /> },
|
||||||
|
Route::Album { id } => html! { <album::AlbumPage {id} /> },
|
||||||
|
Route::Downloads => html! { <downloads::DownloadsPage /> },
|
||||||
|
Route::Settings => html! { <settings::SettingsPage /> },
|
||||||
|
Route::NotFound => html! { <h2>{ "404 — Not Found" }</h2> },
|
||||||
|
}
|
||||||
|
}
|
||||||
214
frontend/src/pages/search.rs
Normal file
214
frontend/src/pages/search.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
enum SearchResults {
|
||||||
|
None,
|
||||||
|
Artists(Vec<ArtistResult>),
|
||||||
|
Albums(Vec<AlbumResult>),
|
||||||
|
Tracks(Vec<TrackResult>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(SearchPage)]
|
||||||
|
pub fn search_page() -> Html {
|
||||||
|
let query = use_state(|| String::new());
|
||||||
|
let search_type = use_state(|| "artist".to_string());
|
||||||
|
let results = use_state(|| SearchResults::None);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
let on_search = {
|
||||||
|
let query = query.clone();
|
||||||
|
let search_type = search_type.clone();
|
||||||
|
let results = results.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
let q = (*query).clone();
|
||||||
|
let st = (*search_type).clone();
|
||||||
|
let results = results.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
error.set(None);
|
||||||
|
match st.as_str() {
|
||||||
|
"artist" => match api::search_artist(&q, 10).await {
|
||||||
|
Ok(r) => results.set(SearchResults::Artists(r)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
},
|
||||||
|
"album" => match api::search_album(&q, None, 10).await {
|
||||||
|
Ok(r) => results.set(SearchResults::Albums(r)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
},
|
||||||
|
"track" => match api::search_track(&q, None, 10).await {
|
||||||
|
Ok(r) => results.set(SearchResults::Tracks(r)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_add_artist = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
move |name: String, mbid: String| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::add_artist(&name, Some(&mbid)).await {
|
||||||
|
Ok(s) => message.set(Some(format!(
|
||||||
|
"Added {} tracks ({} already owned)",
|
||||||
|
s.tracks_added, s.tracks_already_owned
|
||||||
|
))),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_add_album = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
move |artist: String, title: String, mbid: String| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::add_album(&artist, &title, Some(&mbid)).await {
|
||||||
|
Ok(s) => message.set(Some(format!(
|
||||||
|
"Added {} tracks ({} already owned)",
|
||||||
|
s.tracks_added, s.tracks_already_owned
|
||||||
|
))),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Search" }</h2>
|
||||||
|
<p>{ "Find music on MusicBrainz and add to your library" }</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={on_search}>
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={(*query).clone()}
|
||||||
|
oninput={let q = query.clone(); Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
q.set(input.value());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<select onchange={let st = search_type.clone(); Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
st.set(select.value());
|
||||||
|
})}>
|
||||||
|
<option value="artist" selected=true>{ "Artist" }</option>
|
||||||
|
<option value="album">{ "Album" }</option>
|
||||||
|
<option value="track">{ "Track" }</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">{ "Search" }</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
if let Some(ref msg) = *message {
|
||||||
|
<div class="card" style="border-color: var(--success);">
|
||||||
|
<p>{ msg }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
<div class="card error">{ err }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{ match &*results {
|
||||||
|
SearchResults::None => html! {},
|
||||||
|
SearchResults::Artists(items) => html! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Name" }</th><th>{ "Country" }</th><th>{ "Type" }</th><th>{ "Score" }</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for items.iter().map(|a| {
|
||||||
|
let name = a.name.clone();
|
||||||
|
let mbid = a.id.clone();
|
||||||
|
let on_add = on_add_artist.clone();
|
||||||
|
html! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{ &a.name }
|
||||||
|
if let Some(ref d) = a.disambiguation {
|
||||||
|
<span class="text-muted text-sm">{ format!(" ({d})") }</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{ a.country.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>{ a.artist_type.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>{ a.score }</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-success"
|
||||||
|
onclick={Callback::from(move |_| on_add(name.clone(), mbid.clone()))}>
|
||||||
|
{ "Add" }
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
},
|
||||||
|
SearchResults::Albums(items) => html! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Year" }</th><th>{ "Score" }</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for items.iter().map(|a| {
|
||||||
|
let artist = a.artist.clone();
|
||||||
|
let title = a.title.clone();
|
||||||
|
let mbid = a.id.clone();
|
||||||
|
let on_add = on_add_album.clone();
|
||||||
|
html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ &a.title }</td>
|
||||||
|
<td>{ &a.artist }</td>
|
||||||
|
<td>{ a.year.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>{ a.score }</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-success"
|
||||||
|
onclick={Callback::from(move |_| on_add(artist.clone(), title.clone(), mbid.clone()))}>
|
||||||
|
{ "Add" }
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
},
|
||||||
|
SearchResults::Tracks(items) => html! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Album" }</th><th>{ "Score" }</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for items.iter().map(|t| html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ &t.title }</td>
|
||||||
|
<td>{ &t.artist }</td>
|
||||||
|
<td>{ t.album.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>{ t.score }</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
86
frontend/src/pages/settings.rs
Normal file
86
frontend/src/pages/settings.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::types::AppConfig;
|
||||||
|
|
||||||
|
#[function_component(SettingsPage)]
|
||||||
|
pub fn settings_page() -> Html {
|
||||||
|
let config = use_state(|| None::<AppConfig>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
{
|
||||||
|
let config = config.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_config().await {
|
||||||
|
Ok(c) => config.set(Some(c)),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let trigger = |action: &'static str| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let result = match action {
|
||||||
|
"index" => api::trigger_index().await,
|
||||||
|
"tag" => api::trigger_tag().await,
|
||||||
|
"organize" => api::trigger_organize().await,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(t) => message.set(Some(format!("{action} started (task: {})", t.task_id))),
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Settings" }</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if let Some(ref msg) = *message {
|
||||||
|
<div class="card" style="border-color: var(--success);">{ msg }</div>
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
<div class="card error">{ err }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Actions" }</h3>
|
||||||
|
<div class="actions mt-1">
|
||||||
|
<button class="btn btn-primary" onclick={trigger("index")}>{ "Re-index Library" }</button>
|
||||||
|
<button class="btn btn-primary" onclick={trigger("tag")}>{ "Auto-tag Tracks" }</button>
|
||||||
|
<button class="btn btn-primary" onclick={trigger("organize")}>{ "Organize Files" }</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ match &*config {
|
||||||
|
None => html! { <p class="loading">{ "Loading configuration..." }</p> },
|
||||||
|
Some(c) => html! {
|
||||||
|
<div class="card mt-2">
|
||||||
|
<h3>{ "Configuration" }</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-muted">{ "Library Path" }</td><td>{ &c.library_path }</td></tr>
|
||||||
|
<tr><td class="text-muted">{ "Database URL" }</td><td class="text-sm">{ &c.database_url }</td></tr>
|
||||||
|
<tr><td class="text-muted">{ "Download Path" }</td><td>{ &c.download_path }</td></tr>
|
||||||
|
<tr><td class="text-muted">{ "Organization Format" }</td><td><code>{ &c.organization_format }</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
166
frontend/src/types.rs
Normal file
166
frontend/src/types.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// --- Library entities ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Artist {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub musicbrainz_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub album_artist: String,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub musicbrainz_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Track {
|
||||||
|
pub id: i32,
|
||||||
|
pub file_path: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub codec: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct ArtistDetail {
|
||||||
|
pub artist: Artist,
|
||||||
|
pub albums: Vec<Album>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct AlbumDetail {
|
||||||
|
pub album: Album,
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search results ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct ArtistResult {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub disambiguation: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub artist_type: Option<String>,
|
||||||
|
pub score: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct AlbumResult {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub artist_id: Option<String>,
|
||||||
|
pub year: Option<String>,
|
||||||
|
pub track_count: Option<i32>,
|
||||||
|
pub score: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct TrackResult {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub artist_id: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub score: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Watchlist ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct WatchListEntry {
|
||||||
|
pub id: i32,
|
||||||
|
pub item_type: String,
|
||||||
|
pub name: String,
|
||||||
|
pub artist_name: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Downloads ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct DownloadItem {
|
||||||
|
pub id: i32,
|
||||||
|
pub query: String,
|
||||||
|
pub status: String,
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub retry_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tasks ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct TaskInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub task_type: String,
|
||||||
|
pub status: String,
|
||||||
|
pub result: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct TaskRef {
|
||||||
|
pub task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct Status {
|
||||||
|
pub library: LibrarySummary,
|
||||||
|
pub queue: QueueStatus,
|
||||||
|
pub tasks: Vec<TaskInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct LibrarySummary {
|
||||||
|
pub total_items: u64,
|
||||||
|
pub wanted: u64,
|
||||||
|
pub available: u64,
|
||||||
|
pub downloaded: u64,
|
||||||
|
pub owned: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct QueueStatus {
|
||||||
|
pub pending: usize,
|
||||||
|
pub downloading: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API responses ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct AddSummary {
|
||||||
|
pub tracks_added: u64,
|
||||||
|
pub tracks_already_owned: u64,
|
||||||
|
pub errors: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct SyncStats {
|
||||||
|
pub found: u64,
|
||||||
|
pub enqueued: u64,
|
||||||
|
pub skipped: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub library_path: String,
|
||||||
|
pub database_url: String,
|
||||||
|
pub download_path: String,
|
||||||
|
pub organization_format: String,
|
||||||
|
}
|
||||||
164
frontend/style.css
Normal file
164
frontend/style.css
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--bg-card: #334155;
|
||||||
|
--bg-input: #1e293b;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #eab308;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--border: #475569;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app { display: flex; min-height: 100vh; }
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar h1 { font-size: 1.5rem; margin-bottom: 2rem; color: var(--accent); }
|
||||||
|
.sidebar nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.sidebar nav a:hover, .sidebar nav a.active {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
margin-left: 220px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card h3 { margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* Stats grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-card .value { font-size: 2rem; font-weight: bold; color: var(--accent); }
|
||||||
|
.stat-card .label { font-size: 0.85rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||||
|
th { color: var(--text-secondary); font-weight: 600; font-size: 0.85rem; text-transform: uppercase; }
|
||||||
|
tr:hover { background: var(--bg-card); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-success { background: var(--success); color: white; }
|
||||||
|
.btn-danger { background: var(--danger); color: white; }
|
||||||
|
.btn-secondary { background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border); }
|
||||||
|
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
input, select {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
|
||||||
|
.search-bar input { flex: 1; }
|
||||||
|
.search-bar select { width: auto; min-width: 120px; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-wanted { background: var(--warning); color: #000; }
|
||||||
|
.badge-available { background: var(--accent); color: white; }
|
||||||
|
.badge-downloaded { background: var(--success); color: white; }
|
||||||
|
.badge-owned { background: #6366f1; color: white; }
|
||||||
|
.badge-pending { background: var(--text-muted); color: white; }
|
||||||
|
.badge-failed { background: var(--danger); color: white; }
|
||||||
|
.badge-completed { background: var(--success); color: white; }
|
||||||
|
|
||||||
|
/* Page header */
|
||||||
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
|
.page-header h2 { font-size: 1.5rem; }
|
||||||
|
.page-header p { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.gap-1 { gap: 0.5rem; }
|
||||||
|
.gap-2 { gap: 1rem; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.text-muted { color: var(--text-secondary); }
|
||||||
|
.text-sm { font-size: 0.85rem; }
|
||||||
|
.loading { color: var(--text-muted); font-style: italic; }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
37
src/main.rs
37
src/main.rs
@@ -66,8 +66,30 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tasks: TaskManager::new(),
|
tasks: TaskManager::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve static files directory relative to the binary location
|
||||||
|
let static_dir = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|exe| exe.parent().map(|p| p.to_owned()))
|
||||||
|
.map(|p| p.join("static"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("static"));
|
||||||
|
|
||||||
|
// Also check next to the crate root (for development)
|
||||||
|
let static_dir = if static_dir.is_dir() {
|
||||||
|
static_dir
|
||||||
|
} else {
|
||||||
|
let dev_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static");
|
||||||
|
if dev_path.is_dir() {
|
||||||
|
dev_path
|
||||||
|
} else {
|
||||||
|
tracing::warn!("static directory not found — frontend will not be served");
|
||||||
|
static_dir
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!(path = %static_dir.display(), "serving static files");
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
let cors = Cors::permissive();
|
let cors = Cors::permissive();
|
||||||
|
let static_dir = static_dir.clone();
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
@@ -75,10 +97,23 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.app_data(state.clone())
|
.app_data(state.clone())
|
||||||
.configure(routes::configure)
|
.configure(routes::configure)
|
||||||
.service(
|
.service(
|
||||||
actix_files::Files::new("/", "static/")
|
actix_files::Files::new("/", static_dir.clone())
|
||||||
.index_file("index.html")
|
.index_file("index.html")
|
||||||
.prefer_utf8(true),
|
.prefer_utf8(true),
|
||||||
)
|
)
|
||||||
|
// SPA fallback: serve index.html for any route not matched
|
||||||
|
// by API or static files, so client-side routing works on refresh
|
||||||
|
.default_service(web::to({
|
||||||
|
let index_path = static_dir.join("index.html");
|
||||||
|
move |req: actix_web::HttpRequest| {
|
||||||
|
let index_path = index_path.clone();
|
||||||
|
async move {
|
||||||
|
actix_files::NamedFile::open_async(index_path)
|
||||||
|
.await
|
||||||
|
.map(|f| f.into_response(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
.bind(&bind)?
|
.bind(&bind)?
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user