Files
web/frontend/src/pages/settings.rs
2026-03-20 20:04:35 -04:00

751 lines
38 KiB
Rust

use web_sys::HtmlInputElement;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use crate::api;
use crate::types::{AppConfig, SubsonicPasswordStatus, YtAuthStatus};
#[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 ytauth = use_state(|| None::<YtAuthStatus>);
let ytauth_loading = use_state(|| false);
let subsonic_status = use_state(|| None::<SubsonicPasswordStatus>);
let subsonic_password = use_state(String::new);
let subsonic_saving = use_state(|| false);
{
let config = config.clone();
let error = error.clone();
let ytauth = ytauth.clone();
let subsonic_status = subsonic_status.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)),
}
});
wasm_bindgen_futures::spawn_local(async move {
if let Ok(status) = api::get_ytauth_status().await {
ytauth.set(Some(status));
}
});
wasm_bindgen_futures::spawn_local(async move {
if let Ok(status) = api::get_subsonic_password_status().await {
subsonic_status.set(Some(status));
}
});
});
}
let on_save = {
let config = config.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let config = config.clone();
let message = message.clone();
let error = error.clone();
if let Some(ref c) = *config {
let c = c.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::save_config(&c).await {
Ok(updated) => {
config.set(Some(updated));
message.set(Some("Settings saved".to_string()));
error.set(None);
}
Err(e) => error.set(Some(e.0)),
}
});
}
})
};
// Helper to update a field in config state
macro_rules! field_setter {
($config:expr, $field:ident, $val:expr) => {{
let mut c = (*$config).clone().unwrap();
c.$field = $val;
$config.set(Some(c));
}};
}
if let Some(ref err) = *error {
if config.is_none() {
return html! { <div class="error">{ format!("Error: {err}") }</div> };
}
}
let Some(ref c) = *config else {
return html! { <p class="loading">{ "Loading configuration..." }</p> };
};
// Build YouTube auth card HTML outside the main html! macro
let ytauth_html = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
if let Some(ref status) = *ytauth {
if status.login_session_active {
let vnc_url = status.vnc_url.clone().unwrap_or_default();
let on_done = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
ytauth_loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_login_stop().await {
Ok(_) => {
message.set(Some(
"YouTube login complete! Cookies exported.".into(),
));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
ytauth_loading.set(false);
});
})
};
html! {
<>
<p class="text-sm">{ "Log into YouTube in the browser below, then click Done." }</p>
if !vnc_url.is_empty() {
<p class="text-sm"><a href={vnc_url.clone()} target="_blank">{ "Open login window" }</a></p>
<iframe src={vnc_url} style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);margin:0.5rem 0;" />
}
<button class="btn btn-primary" onclick={on_done} disabled={*ytauth_loading}>
{ if *ytauth_loading { "Finishing..." } else { "Done \u{2014} I've logged in" } }
</button>
</>
}
} else if status.authenticated {
let age_text = status
.cookie_age_hours
.map(|h| format!("cookies {h:.0}h old"))
.unwrap_or_else(|| "authenticated".into());
let on_refresh = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
ytauth_loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_refresh().await {
Ok(_) => {
message.set(Some("Cookies refreshed".into()));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
ytauth_loading.set(false);
});
})
};
let on_clear = {
let ytauth = ytauth.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let message = message.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_clear_cookies().await {
Ok(_) => {
message.set(Some("YouTube auth cleared".into()));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
});
})
};
html! {
<>
<p>
<span class="badge badge-success">{ "Authenticated" }</span>
<span class="text-muted text-sm" style="margin-left: 0.5rem;">{ age_text }</span>
</p>
if status.refresh_enabled {
<p class="text-sm text-muted">{ "Auto-refresh is enabled" }</p>
}
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
<button class="btn btn-sm btn-secondary" onclick={on_refresh} disabled={*ytauth_loading}>
{ if *ytauth_loading { "Refreshing..." } else { "Refresh Now" } }
</button>
<button class="btn btn-sm btn-danger" onclick={on_clear}>
{ "Clear Authentication" }
</button>
</div>
</>
}
} else {
let on_start = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let error = error.clone();
ytauth_loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_login_start().await {
Ok(_) => {
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
ytauth_loading.set(false);
});
})
};
html! {
<>
<p class="text-muted text-sm">
{ "Authenticate with YouTube for higher download rate limits (~2000/hr vs ~300/hr) and access to age-restricted content. " }
{ "This launches a browser where you log into your Google account." }
</p>
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "Warning: " }</strong>
{ "YouTube may permanently suspend accounts that are used with third-party download tools. " }
<strong>{ "Use a throwaway Google account" }</strong>
{ " \u{2014} do not log in with your primary account." }
</p>
</div>
<button class="btn btn-primary" onclick={on_start} disabled={*ytauth_loading}>
{ if *ytauth_loading { "Starting..." } else { "Authenticate YouTube" } }
</button>
</>
}
}
} else {
html! { <p class="text-muted text-sm">{ "Loading..." }</p> }
}
};
let ytdlp_version_html = if let Some(ref status) = *ytauth {
let version = status
.ytdlp_version
.clone()
.unwrap_or_else(|| "not found".into());
if status.ytdlp_update_available {
let latest = status.ytdlp_latest.clone().unwrap_or_default();
html! {
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0 0 0.75rem 0; padding: 0.5rem 0.75rem;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "yt-dlp update available: " }</strong>
{ format!("{version} \u{2192} {latest}") }
<span class="text-muted">{ " \u{2014} run " }</span>
<code>{ "pip install -U yt-dlp" }</code>
</p>
</div>
}
} else {
html! {
<p class="text-muted text-sm" style="margin: 0 0 0.75rem 0;">
{ format!("yt-dlp {version}") }
<span style="color: var(--success); margin-left: 0.3rem;">{ "\u{2713} up to date" }</span>
</p>
}
}
} else {
html! {}
};
let lastfm_key_html = {
let key_set = ytauth
.as_ref()
.map(|s| s.lastfm_api_key_set)
.unwrap_or(false);
if key_set {
html! {
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
<span style="color: var(--success);">{ "\u{2713}" }</span>
{ " API key configured via " }
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
</p>
}
} else {
html! {
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
{ "Set " }
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
{ " environment variable. Get a key at " }
<a href="https://www.last.fm/api/account/create" target="_blank">{ "last.fm/api/account/create" }</a>
{ " (use any name, leave callback URL blank)." }
</p>
}
}
};
let fanart_key_html = {
let key_set = ytauth
.as_ref()
.map(|s| s.fanart_api_key_set)
.unwrap_or(false);
if key_set {
html! {
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
<span style="color: var(--success);">{ "\u{2713}" }</span>
{ " API key configured via " }
<code>{ "SHANTY_FANART_API_KEY" }</code>
{ ". Provides artist thumbnails and HD banners." }
</p>
}
} else {
html! {
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
{ "Set " }
<code>{ "SHANTY_FANART_API_KEY" }</code>
{ " environment variable. Get a key at " }
<a href="https://fanart.tv/get-an-api-key/" target="_blank">{ "fanart.tv" }</a>
{ "." }
</p>
}
}
};
html! {
<div>
<div class="page-header">
<h2>{ "Settings" }</h2>
</div>
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>
}
<form onsubmit={on_save}>
// Paths
<div class="card">
<h3>{ "Paths" }</h3>
<div class="form-group">
<label>{ "Library Path" }</label>
<input type="text" value={c.library_path.clone()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
field_setter!(config, library_path, input.value());
})} />
</div>
<div class="form-group">
<label>{ "Download Path" }</label>
<input type="text" value={c.download_path.clone()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
field_setter!(config, download_path, input.value());
})} />
</div>
<div class="form-group">
<label>{ "Organization Format" }</label>
<input type="text" value={c.organization_format.clone()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
field_setter!(config, organization_format, input.value());
})} />
</div>
</div>
// Web Server
<div class="card">
<h3>{ "Web Server" }</h3>
<p class="text-sm text-muted mb-1">{ "Changes require restart" }</p>
<div class="form-group">
<label>{ "Port" }</label>
<input type="number" value={c.web.port.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(port) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.web.port = port;
config.set(Some(cfg));
}
})} />
</div>
<div class="form-group">
<label>{ "Bind Address" }</label>
<input type="text" value={c.web.bind.clone()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.web.bind = input.value();
config.set(Some(cfg));
})} />
</div>
</div>
// Tagging
<div class="card">
<h3>{ "Tagging" }</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" checked={c.tagging.write_tags}
onchange={let config = config.clone(); Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.tagging.write_tags = input.checked();
config.set(Some(cfg));
})} />
{ " Write tags to files" }
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" checked={c.tagging.auto_tag}
onchange={let config = config.clone(); Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.tagging.auto_tag = input.checked();
config.set(Some(cfg));
})} />
{ " Auto-tag after indexing" }
</label>
</div>
<div class="form-group">
<label>{ "Confidence Threshold" }</label>
<input type="number" step="0.05" min="0" max="1" value={c.tagging.confidence.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.tagging.confidence = v;
config.set(Some(cfg));
}
})} />
</div>
</div>
// Downloads
<div class="card">
<h3>{ "Downloads" }</h3>
<div class="form-group">
<label>{ "Audio Format" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.download.format = select.value();
config.set(Some(cfg));
})}>
{ for ["opus", "mp3", "flac", "best"].iter().map(|f| html! {
<option value={*f} selected={c.download.format == *f}>{ f }</option>
})}
</select>
</div>
<div class="form-group">
<label>{ "Search Source" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.download.search_source = select.value();
config.set(Some(cfg));
})}>
{ for [("ytmusic", "YouTube Music"), ("youtube", "YouTube")].iter().map(|(v, label)| html! {
<option value={*v} selected={c.download.search_source == *v}>{ label }</option>
})}
</select>
</div>
<div class="form-group">
<label>{ "Rate Limit (requests/hour, guest)" }</label>
<input type="number" value={c.download.rate_limit.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.download.rate_limit = v;
config.set(Some(cfg));
}
})} />
</div>
<div class="form-group">
<label>{ "Rate Limit (requests/hour, with cookies)" }</label>
<input type="number" value={c.download.rate_limit_auth.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.download.rate_limit_auth = v;
config.set(Some(cfg));
}
})} />
</div>
</div>
// YouTube Authentication
<div class="card">
<h3>{ "YouTube Authentication" }</h3>
{ ytdlp_version_html }
{ ytauth_html }
</div>
// Subsonic API
<div class="card">
<h3>{ "Subsonic API" }</h3>
<p class="text-sm text-muted mb-1">
{ "Connect Subsonic-compatible apps (DSub, Symfonium, Feishin) to stream your library. " }
{ "This is a minimal Subsonic implementation for basic browsing and playback. " }
{ "For a full-featured Subsonic server, consider " }
<a href="https://www.navidrome.org" target="_blank">{ "Navidrome" }</a>
{ " pointed at the same library." }
</p>
<div class="form-group">
<label>{ "Server URL (most clients add /rest automatically)" }</label>
<input type="text" readonly=true value={
format!("http://{}:{}",
c.web.bind.clone(),
c.web.port)
} />
</div>
{
if let Some(ref status) = *subsonic_status {
if status.set {
html! {
<p class="text-sm">
<span class="badge badge-success">{ "Password set" }</span>
</p>
}
} else {
html! {
<p class="text-sm text-muted">{ "No Subsonic password set. Set one below to enable access." }</p>
}
}
} else {
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
}
}
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "Warning: " }</strong>
{ "This password is stored in plaintext per the Subsonic protocol. Do " }
<strong>{ "not" }</strong>
{ " reuse a password from another account." }
</p>
</div>
<div class="form-group">
<label>{ "Subsonic Password" }</label>
<input type="password" placeholder="Enter Subsonic password"
value={(*subsonic_password).clone()}
oninput={let subsonic_password = subsonic_password.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
subsonic_password.set(input.value());
})} />
</div>
<button type="button" class="btn btn-primary"
disabled={*subsonic_saving || subsonic_password.is_empty()}
onclick={{
let subsonic_password = subsonic_password.clone();
let subsonic_saving = subsonic_saving.clone();
let subsonic_status = subsonic_status.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let pw = (*subsonic_password).clone();
let subsonic_saving = subsonic_saving.clone();
let subsonic_status = subsonic_status.clone();
let subsonic_password = subsonic_password.clone();
let message = message.clone();
let error = error.clone();
subsonic_saving.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::set_subsonic_password(&pw).await {
Ok(_) => {
message.set(Some("Subsonic password saved".into()));
subsonic_password.set(String::new());
if let Ok(s) = api::get_subsonic_password_status().await {
subsonic_status.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
subsonic_saving.set(false);
});
})
}}>
{ if *subsonic_saving { "Saving..." } else { "Save Subsonic Password" } }
</button>
</div>
// Metadata Providers
<div class="card">
<h3>{ "Metadata Providers" }</h3>
<div class="form-group">
<label>{ "Music Database" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.metadata.metadata_source = select.value();
config.set(Some(cfg));
})}>
{ for [("musicbrainz", "MusicBrainz")].iter().map(|(v, label)| html! {
<option value={*v} selected={c.metadata.metadata_source == *v}>{ label }</option>
})}
</select>
</div>
<div class="form-group">
<label>{ "Artist Images" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.metadata.artist_image_source = select.value();
config.set(Some(cfg));
})}>
{ for [("wikipedia", "Wikipedia"), ("fanarttv", "fanart.tv")].iter().map(|(v, label)| html! {
<option value={*v} selected={c.metadata.artist_image_source == *v}>{ label }</option>
})}
</select>
</div>
if c.metadata.artist_image_source == "fanarttv" {
{ fanart_key_html.clone() }
}
<div class="form-group">
<label>{ "Artist Bios" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.metadata.artist_bio_source = select.value();
config.set(Some(cfg));
})}>
{ for [("wikipedia", "Wikipedia"), ("lastfm", "Last.fm")].iter().map(|(v, label)| html! {
<option value={*v} selected={c.metadata.artist_bio_source == *v}>{ label }</option>
})}
</select>
</div>
if c.metadata.artist_bio_source == "lastfm" {
{ lastfm_key_html.clone() }
}
<div class="form-group">
<label>{ "Lyrics" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.metadata.lyrics_source = select.value();
config.set(Some(cfg));
})}>
{ for [("lrclib", "LRCLIB")].iter().map(|(v, label)| html! {
<option value={*v} selected={c.metadata.lyrics_source == *v}>{ label }</option>
})}
</select>
</div>
<div class="form-group">
<label>{ "Cover Art" }</label>
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.metadata.cover_art_source = select.value();
config.set(Some(cfg));
})}>
{ for [("coverartarchive", "Cover Art Archive")].iter().map(|(v, label)| html! {
<option value={*v} selected={c.metadata.cover_art_source == *v}>{ label }</option>
})}
</select>
</div>
</div>
// Scheduling
<div class="card">
<h3>{ "Scheduling" }</h3>
<p class="text-sm text-muted mb-1">{ "Automate pipeline runs and new release monitoring" }</p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" checked={c.scheduling.pipeline_enabled}
onchange={let config = config.clone(); Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.pipeline_enabled = input.checked();
config.set(Some(cfg));
})} />
{ " Automatically run full pipeline (sync, download, index, tag, organize)" }
</label>
</div>
<div class="form-group">
<label>{ "Pipeline Interval (hours)" }</label>
<input type="number" min="1" max="168" value={c.scheduling.pipeline_interval_hours.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.pipeline_interval_hours = v;
config.set(Some(cfg));
}
})} />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" checked={c.scheduling.monitor_enabled}
onchange={let config = config.clone(); Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.monitor_enabled = input.checked();
config.set(Some(cfg));
})} />
{ " Automatically check monitored artists for new releases" }
</label>
</div>
<div class="form-group">
<label>{ "Monitor Interval (hours)" }</label>
<input type="number" min="1" max="168" value={c.scheduling.monitor_interval_hours.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.scheduling.monitor_interval_hours = v;
config.set(Some(cfg));
}
})} />
</div>
</div>
// Indexing
<div class="card">
<h3>{ "Indexing" }</h3>
<div class="form-group">
<label>{ "Concurrency (file processors)" }</label>
<input type="number" min="1" max="32" value={c.indexing.concurrency.to_string()}
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
let mut cfg = (*config).clone().unwrap();
cfg.indexing.concurrency = v;
config.set(Some(cfg));
}
})} />
</div>
</div>
<button type="submit" class="btn btn-primary">{ "Save Settings" }</button>
</form>
</div>
}
}