Major early updates

This commit is contained in:
Connor Johnstone
2026-03-18 10:56:19 -04:00
parent 50a0ddcdbc
commit 55607df07b
20 changed files with 2665 additions and 1 deletions

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

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

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

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

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

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

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