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_svg;
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_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]
78pub 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]
115fn 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]
237fn 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]
272fn 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