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