//! Upload UI: the bottom-right overlay for active jobs, and the `#/uploads` //! view that lists every job of this session as a log. use leptos::prelude::*; use crate::components::icon::icon_svg; use crate::i18n; use crate::icons::IconName; use crate::uploads::{self, Job, JobState, OnConflict, format_speed, speeds}; use crate::util::format_size; /// Bottom-right overlay: active jobs, and finished ones for a few seconds. /// Hidden when it has nothing to show. Collapses to its header line. #[component] pub fn UploadPanel() -> impl IntoView { let jobs = uploads::jobs(); let collapsed = uploads::collapsed(); let shown = Memo::new(move |_| { jobs.with(|list| { list.iter() .filter(|j| !j.hidden) .map(|j| j.id) .collect::>() }) }); let summary = move || { jobs.with(|list| { let active: Vec<&Job> = list.iter().filter(|j| j.state.is_active()).collect(); if active.is_empty() { return i18n::t(i18n::k::UPLOAD_COMPLETE).to_string(); } let total: f64 = active.iter().map(|j| j.total).sum(); let sent: f64 = active.iter().map(|j| j.sent()).sum(); let pct = if total > 0.0 { sent / total * 100.0 } else { 0.0 }; format!( "{} · {pct:.0}%", i18n::t_fmt(i18n::k::UPLOAD_ACTIVE_N, &active.len().to_string()) ) }) }; view! {
{icon_svg(IconName::Upload, "ic-btn")} {i18n::tr(i18n::k::UPLOADS)} {summary}
} } /// The `#/uploads` section: every job of this session, clearable. #[component] pub fn UploadsView() -> impl IntoView { let jobs = uploads::jobs(); let ids = Memo::new(move |_| jobs.with(|list| list.iter().rev().map(|j| j.id).collect::>())); let any_finished = move || jobs.with(|list| list.iter().any(|j| j.is_finished())); view! {

{i18n::tr(i18n::k::UPLOADS)}

{icon_svg(IconName::Upload, "empty-glyph")}

{i18n::tr(i18n::k::UPLOADS_EMPTY)}

} >
} } /// One job: label, progress bar, state, controls, and the speed graph when /// expanded. `in_log` adds the clear button for finished jobs. #[component] fn JobRow(id: u64, in_log: bool) -> impl IntoView { let jobs = uploads::jobs(); // The row's own slice of the list. Every progress event updates the // list; this keeps a row from re-rendering for another job's bytes. let job = Memo::new(move |_| jobs.with(|list| list.iter().find(|j| j.id == id).cloned())); let with = move |f: &dyn Fn(&Job) -> String| job.with(|j| j.as_ref().map(f).unwrap_or_default()); let state_text = move || { job.with(|j| { let Some(j) = j else { return String::new() }; match &j.state { JobState::Preparing => i18n::t(i18n::k::UPLOAD_PREPARING).to_string(), JobState::Running => { let mut s = format!( "{} / {}", format_size(j.sent() as u64), format_size(j.total as u64) ); if j.file_count > 1 { let n = (j.current + 1).min(j.file_count); s.push_str(&format!(" · {}/{}", n, j.file_count)); } s.push_str(&format!( " · {} · {}", format_speed(j.speed()), j.current_name )); s } JobState::Paused => i18n::t(i18n::k::UPLOAD_PAUSED).to_string(), JobState::Conflict(_) => i18n::t(i18n::k::UPLOAD_WAITING).to_string(), JobState::Done => { let mut s = format!( "{} · {}", i18n::t(i18n::k::UPLOAD_COMPLETE), format_size(j.total as u64) ); if !j.skipped.is_empty() { s.push_str(" · "); s.push_str(&i18n::t_fmt( i18n::k::UPLOAD_SKIPPED_N, &j.skipped.len().to_string(), )); } if !j.errors.is_empty() { s.push_str(" · "); s.push_str(&i18n::t_fmt( i18n::k::UPLOAD_ERRORS_N, &j.errors.len().to_string(), )); } s } JobState::Failed(msg) => format!("{}: {msg}", i18n::t(i18n::k::UPLOAD_FAILED)), JobState::Aborted => i18n::t(i18n::k::UPLOAD_ABORTED).to_string(), } }) }; let is = move |f: fn(&JobState) -> bool| job.with(|j| j.as_ref().is_some_and(|j| f(&j.state))); let failed = move || is(|s| matches!(s, JobState::Failed(_) | JobState::Aborted)); let has_errors = move || job.with(|j| j.as_ref().is_some_and(|j| !j.errors.is_empty())); let expanded = move || job.with(|j| j.as_ref().is_some_and(|j| j.expanded)); view! {
{move || with(&|j| j.label.clone())} {move || with(&|j| format!("{:.0}%", j.percent()))}
{state_text}
    {move || job.with(|j| j.as_ref().map(|j| j.errors.iter().map(|(rel, msg)| { view! {
  • {rel.clone()}" — "{msg.clone()}
  • } }).collect::>()))}
} } /// Inline prompt for a file that appeared during the transfer. #[component] fn ConflictPrompt(id: u64, job: Memo>) -> impl IntoView { let (apply_all, set_apply_all) = signal(true); let file = move || { job.with(|j| match j.as_ref().map(|j| &j.state) { Some(JobState::Conflict(rel)) => rel.clone(), _ => String::new(), }) }; view! {
{move || i18n::t_fmt(i18n::k::UPLOAD_EXISTS_ASK, &file())}
} } /// Transfer speed over the kept samples as one SVG polyline, auto-scaled. #[component] fn SpeedGraph(job: Memo>) -> impl IntoView { const W: f64 = 300.0; const H: f64 = 60.0; let points = move || { job.with(|j| { let Some(j) = j else { return (String::new(), 0.0); }; let speeds = speeds(&j.samples.iter().copied().collect::>()); if speeds.is_empty() { return (String::new(), 0.0); } let max = speeds.iter().cloned().fold(0.0, f64::max).max(1.0); let n = speeds.len().max(2) as f64 - 1.0; let pts = speeds .iter() .enumerate() .map(|(i, s)| format!("{:.1},{:.1}", i as f64 / n * W, H - s / max * (H - 4.0))) .collect::>() .join(" "); (pts, max) }) }; view! {
{move || format_speed(points().1)}
} }