uploads.rs
| 1 | //! Upload UI: the bottom-right overlay for active jobs, and the `#/uploads` |
| 2 | //! view that lists every job of this session as a log. |
| 3 | |
| 4 | use leptos::prelude::*; |
| 5 | |
| 6 | use crate::components::icon::icon_svg; |
| 7 | use crate::i18n; |
| 8 | use crate::icons::IconName; |
| 9 | use crate::uploads::{self, Job, JobState, OnConflict, format_speed, speeds}; |
| 10 | use crate::util::format_size; |
| 11 | |
| 12 | /// Bottom-right overlay: active jobs, and finished ones for a few seconds. |
| 13 | /// Hidden when it has nothing to show. Collapses to its header line. |
| 14 | #[component] |
| 15 | pub fn UploadPanel() -> impl IntoView { |
| 16 | let jobs = uploads::jobs(); |
| 17 | let collapsed = uploads::collapsed(); |
| 18 | let shown = Memo::new(move |_| { |
| 19 | jobs.with(|list| { |
| 20 | list.iter() |
| 21 | .filter(|j| !j.hidden) |
| 22 | .map(|j| j.id) |
| 23 | .collect::<Vec<u64>>() |
| 24 | }) |
| 25 | }); |
| 26 | let summary = move || { |
| 27 | jobs.with(|list| { |
| 28 | let active: Vec<&Job> = list.iter().filter(|j| j.state.is_active()).collect(); |
| 29 | if active.is_empty() { |
| 30 | return i18n::t(i18n::k::UPLOAD_COMPLETE).to_string(); |
| 31 | } |
| 32 | let total: f64 = active.iter().map(|j| j.total).sum(); |
| 33 | let sent: f64 = active.iter().map(|j| j.sent()).sum(); |
| 34 | let pct = if total > 0.0 { |
| 35 | sent / total * 100.0 |
| 36 | } else { |
| 37 | 0.0 |
| 38 | }; |
| 39 | format!( |
| 40 | "{} · {pct:.0}%", |
| 41 | i18n::t_fmt(i18n::k::UPLOAD_ACTIVE_N, &active.len().to_string()) |
| 42 | ) |
| 43 | }) |
| 44 | }; |
| 45 | view! { |
| 46 | <Show when=move || !shown.get().is_empty()> |
| 47 | <div class="upload-panel" class:collapsed=move || collapsed.get()> |
| 48 | <div class="upload-panel-head"> |
| 49 | {icon_svg(IconName::Upload, "ic-btn")} |
| 50 | <span class="upload-panel-title">{i18n::tr(i18n::k::UPLOADS)}</span> |
| 51 | <span class="muted upload-panel-summary">{summary}</span> |
| 52 | <button |
| 53 | class="icon-btn icon-btn-sm" |
| 54 | title=move || if collapsed.get() { |
| 55 | i18n::t(i18n::k::UPLOAD_EXPAND).to_string() |
| 56 | } else { |
| 57 | i18n::t(i18n::k::UPLOAD_COLLAPSE).to_string() |
| 58 | } |
| 59 | on:click=move |_| collapsed.update(|c| *c = !*c) |
| 60 | > |
| 61 | {move || icon_svg(if collapsed.get() { IconName::ExpandLess } else { IconName::ExpandMore }, "ic-btn")} |
| 62 | </button> |
| 63 | </div> |
| 64 | <Show when=move || !collapsed.get()> |
| 65 | <div class="upload-panel-list"> |
| 66 | <For each=move || shown.get() key=|id| *id let:id> |
| 67 | <JobRow id=id in_log=false/> |
| 68 | </For> |
| 69 | </div> |
| 70 | </Show> |
| 71 | </div> |
| 72 | </Show> |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// The `#/uploads` section: every job of this session, clearable. |
| 77 | #[component] |
| 78 | pub fn UploadsView() -> impl IntoView { |
| 79 | let jobs = uploads::jobs(); |
| 80 | let ids = |
| 81 | Memo::new(move |_| jobs.with(|list| list.iter().rev().map(|j| j.id).collect::<Vec<u64>>())); |
| 82 | let any_finished = move || jobs.with(|list| list.iter().any(|j| j.is_finished())); |
| 83 | view! { |
| 84 | <div class="uploads-view"> |
| 85 | <div class="users-header"> |
| 86 | <h2 class="admin-section-title">{i18n::tr(i18n::k::UPLOADS)}</h2> |
| 87 | <Show when=any_finished> |
| 88 | <button class="btn" on:click=move |_| uploads::clear_finished()> |
| 89 | {i18n::tr(i18n::k::UPLOAD_CLEAR_FINISHED)} |
| 90 | </button> |
| 91 | </Show> |
| 92 | </div> |
| 93 | <Show |
| 94 | when=move || !ids.get().is_empty() |
| 95 | fallback=move || view! { |
| 96 | <div class="empty-state"> |
| 97 | {icon_svg(IconName::Upload, "empty-glyph")} |
| 98 | <h3>{i18n::tr(i18n::k::UPLOADS_EMPTY)}</h3> |
| 99 | </div> |
| 100 | } |
| 101 | > |
| 102 | <div class="upload-log"> |
| 103 | <For each=move || ids.get() key=|id| *id let:id> |
| 104 | <JobRow id=id in_log=true/> |
| 105 | </For> |
| 106 | </div> |
| 107 | </Show> |
| 108 | </div> |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// One job: label, progress bar, state, controls, and the speed graph when |
| 113 | /// expanded. `in_log` adds the clear button for finished jobs. |
| 114 | #[component] |
| 115 | fn JobRow(id: u64, in_log: bool) -> impl IntoView { |
| 116 | let jobs = uploads::jobs(); |
| 117 | // The row's own slice of the list. Every progress event updates the |
| 118 | // list; this keeps a row from re-rendering for another job's bytes. |
| 119 | let job = Memo::new(move |_| jobs.with(|list| list.iter().find(|j| j.id == id).cloned())); |
| 120 | let with = |
| 121 | move |f: &dyn Fn(&Job) -> String| job.with(|j| j.as_ref().map(f).unwrap_or_default()); |
| 122 | |
| 123 | let state_text = move || { |
| 124 | job.with(|j| { |
| 125 | let Some(j) = j else { return String::new() }; |
| 126 | match &j.state { |
| 127 | JobState::Preparing => i18n::t(i18n::k::UPLOAD_PREPARING).to_string(), |
| 128 | JobState::Running => { |
| 129 | let mut s = format!( |
| 130 | "{} / {}", |
| 131 | format_size(j.sent() as u64), |
| 132 | format_size(j.total as u64) |
| 133 | ); |
| 134 | if j.file_count > 1 { |
| 135 | let n = (j.current + 1).min(j.file_count); |
| 136 | s.push_str(&format!(" · {}/{}", n, j.file_count)); |
| 137 | } |
| 138 | s.push_str(&format!( |
| 139 | " · {} · {}", |
| 140 | format_speed(j.speed()), |
| 141 | j.current_name |
| 142 | )); |
| 143 | s |
| 144 | } |
| 145 | JobState::Paused => i18n::t(i18n::k::UPLOAD_PAUSED).to_string(), |
| 146 | JobState::Conflict(_) => i18n::t(i18n::k::UPLOAD_WAITING).to_string(), |
| 147 | JobState::Done => { |
| 148 | let mut s = format!( |
| 149 | "{} · {}", |
| 150 | i18n::t(i18n::k::UPLOAD_COMPLETE), |
| 151 | format_size(j.total as u64) |
| 152 | ); |
| 153 | if !j.skipped.is_empty() { |
| 154 | s.push_str(" · "); |
| 155 | s.push_str(&i18n::t_fmt( |
| 156 | i18n::k::UPLOAD_SKIPPED_N, |
| 157 | &j.skipped.len().to_string(), |
| 158 | )); |
| 159 | } |
| 160 | if !j.errors.is_empty() { |
| 161 | s.push_str(" · "); |
| 162 | s.push_str(&i18n::t_fmt( |
| 163 | i18n::k::UPLOAD_ERRORS_N, |
| 164 | &j.errors.len().to_string(), |
| 165 | )); |
| 166 | } |
| 167 | s |
| 168 | } |
| 169 | JobState::Failed(msg) => format!("{}: {msg}", i18n::t(i18n::k::UPLOAD_FAILED)), |
| 170 | JobState::Aborted => i18n::t(i18n::k::UPLOAD_ABORTED).to_string(), |
| 171 | } |
| 172 | }) |
| 173 | }; |
| 174 | let is = move |f: fn(&JobState) -> bool| job.with(|j| j.as_ref().is_some_and(|j| f(&j.state))); |
| 175 | let failed = move || is(|s| matches!(s, JobState::Failed(_) | JobState::Aborted)); |
| 176 | let has_errors = move || job.with(|j| j.as_ref().is_some_and(|j| !j.errors.is_empty())); |
| 177 | let expanded = move || job.with(|j| j.as_ref().is_some_and(|j| j.expanded)); |
| 178 | |
| 179 | view! { |
| 180 | <div class="upload-job" class:failed=failed class:done=move || is(|s| *s == JobState::Done)> |
| 181 | <div class="upload-job-head"> |
| 182 | <span class="upload-job-label" title=move || with(&|j| format!("{}/{}", j.root_id, j.dir))> |
| 183 | {move || with(&|j| j.label.clone())} |
| 184 | </span> |
| 185 | <span class="upload-job-pct">{move || with(&|j| format!("{:.0}%", j.percent()))}</span> |
| 186 | <Show when=move || is(|s| *s == JobState::Running)> |
| 187 | <button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_PAUSE)} on:click=move |_| uploads::pause(id)> |
| 188 | {icon_svg(IconName::Pause, "ic-btn")} |
| 189 | </button> |
| 190 | </Show> |
| 191 | <Show when=move || is(|s| *s == JobState::Paused)> |
| 192 | <button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_RESUME)} on:click=move |_| uploads::resume(id)> |
| 193 | {icon_svg(IconName::Play, "ic-btn")} |
| 194 | </button> |
| 195 | </Show> |
| 196 | <Show when=move || is(JobState::is_active)> |
| 197 | <button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_ABORT)} on:click=move |_| uploads::abort(id)> |
| 198 | {icon_svg(IconName::Stop, "ic-btn")} |
| 199 | </button> |
| 200 | </Show> |
| 201 | <Show when=move || in_log && !is(JobState::is_active)> |
| 202 | <button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_CLEAR)} on:click=move |_| uploads::clear(id)> |
| 203 | {icon_svg(IconName::Close, "ic-btn")} |
| 204 | </button> |
| 205 | </Show> |
| 206 | <button |
| 207 | class="icon-btn icon-btn-sm" |
| 208 | title={i18n::tr(i18n::k::UPLOAD_SHOW_GRAPH)} |
| 209 | on:click=move |_| uploads::toggle_expanded(id) |
| 210 | > |
| 211 | {move || icon_svg(if expanded() { IconName::ExpandLess } else { IconName::ExpandMore }, "ic-btn")} |
| 212 | </button> |
| 213 | </div> |
| 214 | <div class="upload-bar" class:indeterminate=move || is(|s| *s == JobState::Preparing)> |
| 215 | <div class="upload-bar-fill" style:width=move || with(&|j| format!("{:.1}%", j.percent()))></div> |
| 216 | </div> |
| 217 | <div class="upload-job-state muted">{state_text}</div> |
| 218 | <Show when=move || is(|s| matches!(s, JobState::Conflict(_)))> |
| 219 | <ConflictPrompt id=id job=job/> |
| 220 | </Show> |
| 221 | <Show when=expanded> |
| 222 | <SpeedGraph job=job/> |
| 223 | <Show when=has_errors> |
| 224 | <ul class="upload-errors"> |
| 225 | {move || job.with(|j| j.as_ref().map(|j| j.errors.iter().map(|(rel, msg)| { |
| 226 | view! { <li><span class="upload-err-file">{rel.clone()}</span>" — "{msg.clone()}</li> } |
| 227 | }).collect::<Vec<_>>()))} |
| 228 | </ul> |
| 229 | </Show> |
| 230 | </Show> |
| 231 | </div> |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /// Inline prompt for a file that appeared during the transfer. |
| 236 | #[component] |
| 237 | fn ConflictPrompt(id: u64, job: Memo<Option<Job>>) -> impl IntoView { |
| 238 | let (apply_all, set_apply_all) = signal(true); |
| 239 | let file = move || { |
| 240 | job.with(|j| match j.as_ref().map(|j| &j.state) { |
| 241 | Some(JobState::Conflict(rel)) => rel.clone(), |
| 242 | _ => String::new(), |
| 243 | }) |
| 244 | }; |
| 245 | view! { |
| 246 | <div class="upload-conflict"> |
| 247 | <span>{move || i18n::t_fmt(i18n::k::UPLOAD_EXISTS_ASK, &file())}</span> |
| 248 | <label class="upload-conflict-all"> |
| 249 | <input |
| 250 | type="checkbox" |
| 251 | checked=move || apply_all.get() |
| 252 | on:change=move |ev: web_sys::Event| { |
| 253 | set_apply_all.set(event_target_checked(&ev)); |
| 254 | } |
| 255 | /> |
| 256 | {i18n::tr(i18n::k::UPLOAD_APPLY_ALL)} |
| 257 | </label> |
| 258 | <div class="upload-conflict-actions"> |
| 259 | <button class="btn" on:click=move |_| uploads::decide(id, OnConflict::Skip, apply_all.get())> |
| 260 | {i18n::tr(i18n::k::UPLOAD_SKIP)} |
| 261 | </button> |
| 262 | <button class="btn btn-danger" on:click=move |_| uploads::decide(id, OnConflict::Overwrite, apply_all.get())> |
| 263 | {i18n::tr(i18n::k::OVERWRITE)} |
| 264 | </button> |
| 265 | </div> |
| 266 | </div> |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /// Transfer speed over the kept samples as one SVG polyline, auto-scaled. |
| 271 | #[component] |
| 272 | fn SpeedGraph(job: Memo<Option<Job>>) -> impl IntoView { |
| 273 | const W: f64 = 300.0; |
| 274 | const H: f64 = 60.0; |
| 275 | let points = move || { |
| 276 | job.with(|j| { |
| 277 | let Some(j) = j else { |
| 278 | return (String::new(), 0.0); |
| 279 | }; |
| 280 | let speeds = speeds(&j.samples.iter().copied().collect::<Vec<_>>()); |
| 281 | if speeds.is_empty() { |
| 282 | return (String::new(), 0.0); |
| 283 | } |
| 284 | let max = speeds.iter().cloned().fold(0.0, f64::max).max(1.0); |
| 285 | let n = speeds.len().max(2) as f64 - 1.0; |
| 286 | let pts = speeds |
| 287 | .iter() |
| 288 | .enumerate() |
| 289 | .map(|(i, s)| format!("{:.1},{:.1}", i as f64 / n * W, H - s / max * (H - 4.0))) |
| 290 | .collect::<Vec<_>>() |
| 291 | .join(" "); |
| 292 | (pts, max) |
| 293 | }) |
| 294 | }; |
| 295 | view! { |
| 296 | <div class="upload-graph"> |
| 297 | <span class="upload-graph-max muted">{move || format_speed(points().1)}</span> |
| 298 | <svg viewBox=format!("0 0 {W} {H}") preserveAspectRatio="none" aria-hidden="true"> |
| 299 | <polyline points=move || points().0 fill="none" stroke="currentColor" stroke-width="1.5"/> |
| 300 | </svg> |
| 301 | </div> |
| 302 | } |
| 303 | } |
| 304 |