uploads.rs
⎇
Raw
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
4use leptos::prelude::*;
5
6use crate::components::icon::Icon;
7use crate::i18n;
8use crate::icons::IconName;
9use crate::uploads::{self, Job, JobState, OnConflict, format_speed, speeds};
10use 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]
15pub 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]
83pub 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]
120fn 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]
247fn 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]
282fn 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