dialogs.rs
⎇
Raw
1//! Modal dialogs: text prompts (new folder / rename), confirmations (delete),
2//! overwrite conflict warnings, and the move/copy destination picker.
3
4use leptos::prelude::*;
5use wasm_bindgen_futures::spawn_local;
6use web_sys::MouseEvent;
7
8use crate::api::{self, Entry, RootInfo};
9use crate::components::icon::{Icon, IconName};
10
11/// Which mutating operation the destination picker is for.
12#[derive(Clone, Copy, PartialEq)]
13pub enum Op {
14 Move,
15 Copy,
16}
17
18impl Op {
19 pub fn verb(self) -> &'static str {
20 match self {
21 Op::Move => "Move",
22 Op::Copy => "Copy",
23 }
24 }
25}
26
27/// The single active dialog. The browser renders whichever is `Some`.
28#[derive(Clone)]
29pub enum Dialog {
30 /// Text input (new folder, rename).
31 Prompt {
32 title: String,
33 label: String,
34 initial: String,
35 submit: String,
36 on_submit: Callback<String>,
37 },
38 /// Simple confirmation (delete).
39 Confirm {
40 title: String,
41 message: String,
42 submit: String,
43 danger: bool,
44 on_submit: Callback<()>,
45 },
46 /// Overwrite warning listing the conflicting files.
47 Conflict {
48 title: String,
49 files: Vec<String>,
50 on_submit: Callback<()>,
51 },
52 /// Move/copy destination picker.
53 Picker {
54 op: Op,
55 entry: Entry,
56 roots: Vec<RootInfo>,
57 source_root: i64,
58 source_dir: String,
59 on_pick: Callback<(i64, String)>,
60 },
61}
62
63/// Split a "/"-separated directory path into its non-empty segments.
64fn dir_segs(dir: &str) -> Vec<String> {
65 dir.split('/')
66 .filter(|s| !s.is_empty())
67 .map(|s| s.to_string())
68 .collect()
69}
70
71#[component]
72pub fn DialogView(
73 dialog: ReadSignal<Option<Dialog>>,
74 set_dialog: WriteSignal<Option<Dialog>>,
75) -> impl IntoView {
76 view! {
77 {move || {
78 let Some(d) = dialog.get() else {
79 return view! {}.into_any();
80 };
81 let close = Callback::new(move |_| set_dialog.set(None));
82 match &d {
83 Dialog::Prompt {
84 title,
85 label,
86 initial,
87 submit,
88 on_submit,
89 } => {
90 let (title, label, initial, submit, on_submit, close) = (
91 title.clone(),
92 label.clone(),
93 initial.clone(),
94 submit.clone(),
95 on_submit.clone(),
96 close.clone(),
97 );
98 view! {
99 <PromptDialog
100 title=title
101 label=label
102 initial=initial
103 submit=submit
104 on_submit=on_submit
105 on_close=close
106 />
107 }
108 .into_view()
109 .into_any()
110 }
111 Dialog::Confirm {
112 title,
113 message,
114 submit,
115 danger,
116 on_submit,
117 } => {
118 let (title, message, submit, danger, on_submit, close) = (
119 title.clone(),
120 message.clone(),
121 submit.clone(),
122 *danger,
123 on_submit.clone(),
124 close.clone(),
125 );
126 view! {
127 <ConfirmDialog
128 title=title
129 message=message
130 submit=submit
131 danger=danger
132 on_submit=on_submit
133 on_close=close
134 />
135 }
136 .into_view()
137 .into_any()
138 }
139 Dialog::Conflict {
140 title,
141 files,
142 on_submit,
143 } => {
144 let (title, files, on_submit, close) = (
145 title.clone(),
146 files.clone(),
147 on_submit.clone(),
148 close.clone(),
149 );
150 view! {
151 <ConflictDialog
152 title=title
153 files=files
154 on_submit=on_submit
155 on_close=close
156 />
157 }
158 .into_view()
159 .into_any()
160 }
161 Dialog::Picker {
162 op,
163 entry,
164 roots,
165 source_root,
166 source_dir,
167 on_pick,
168 } => {
169 let (op, entry, roots, source_root, source_dir, on_pick, close) = (
170 *op,
171 entry.clone(),
172 roots.clone(),
173 *source_root,
174 source_dir.clone(),
175 on_pick.clone(),
176 close.clone(),
177 );
178 view! {
179 <PickerDialog
180 op=op
181 entry=entry
182 roots=roots
183 source_root=source_root
184 source_dir=source_dir
185 on_pick=on_pick
186 on_close=close
187 />
188 }
189 .into_view()
190 .into_any()
191 }
192 }
193 }}
194 }
195}
196
197// ---------------------------------------------------------------------------
198// Prompt (text input)
199// ---------------------------------------------------------------------------
200
201#[component]
202fn PromptDialog(
203 title: String,
204 label: String,
205 initial: String,
206 submit: String,
207 on_submit: Callback<String>,
208 on_close: Callback<()>,
209) -> impl IntoView {
210 let input_id = "fbng-prompt-input";
211 let id = input_id.to_string();
212 let id2 = id.clone();
213 let submit_cb = on_submit;
214 view! {
215 <div class="modal-overlay" on:click=move |_| on_close.run(())>
216 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
217 <h2 class="modal-title">{title}</h2>
218 <label class="modal-label" for=input_id>{label}</label>
219 <input
220 id=input_id
221 class="input"
222 value=initial
223 on:keydown=move |e: web_sys::KeyboardEvent| {
224 if e.key() == "Enter" {
225 e.prevent_default();
226 submit_cb.run(api::input_value(&id));
227 }
228 }
229 />
230 <div class="modal-actions">
231 <button class="btn" on:click=move |_| on_close.run(())>
232 "Cancel"
233 </button>
234 <button
235 class="btn btn-primary"
236 on:click=move |_| submit_cb.run(api::input_value(&id2))
237 >
238 {submit}
239 </button>
240 </div>
241 </div>
242 </div>
243 }
244}
245
246// ---------------------------------------------------------------------------
247// Confirm
248// ---------------------------------------------------------------------------
249
250#[component]
251fn ConfirmDialog(
252 title: String,
253 message: String,
254 submit: String,
255 danger: bool,
256 on_submit: Callback<()>,
257 on_close: Callback<()>,
258) -> impl IntoView {
259 view! {
260 <div class="modal-overlay" on:click=move |_| on_close.run(())>
261 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
262 <h2 class="modal-title">{title}</h2>
263 <p class="modal-message">{message}</p>
264 <div class="modal-actions">
265 <button class="btn" on:click=move |_| on_close.run(())>
266 "Cancel"
267 </button>
268 <button
269 class=move || {
270 if danger {
271 "btn btn-danger".to_string()
272 } else {
273 "btn btn-primary".to_string()
274 }
275 }
276 on:click=move |_| {
277 on_submit.run(());
278 on_close.run(());
279 }
280 >
281 {submit}
282 </button>
283 </div>
284 </div>
285 </div>
286 }
287}
288
289// ---------------------------------------------------------------------------
290// Conflict (overwrite warning)
291// ---------------------------------------------------------------------------
292
293#[component]
294fn ConflictDialog(
295 title: String,
296 files: Vec<String>,
297 on_submit: Callback<()>,
298 on_close: Callback<()>,
299) -> impl IntoView {
300 let shown: Vec<String> = files.iter().take(20).cloned().collect();
301 let extra = files.len().saturating_sub(shown.len());
302 view! {
303 <div class="modal-overlay" on:click=move |_| on_close.run(())>
304 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
305 <h2 class="modal-title">{title}</h2>
306 <p class="modal-message">
307 "The following files already exist. Overwrite them?"
308 </p>
309 <ul class="conflict-list">
310 {shown.iter().map(|f| {
311 let f = f.clone();
312 view! { <li>{f}</li> }
313 }).collect::<Vec<_>>()}
314 {move || {
315 if extra > 0 {
316 view! { <li class="muted">format!("…and {extra} more") </li> }
317 .into_view()
318 .into_any()
319 } else {
320 view! {}.into_any()
321 }
322 }}
323 </ul>
324 <div class="modal-actions">
325 <button class="btn" on:click=move |_| on_close.run(())>
326 "Cancel"
327 </button>
328 <button
329 class="btn btn-danger"
330 on:click=move |_| {
331 on_submit.run(());
332 on_close.run(());
333 }
334 >
335 "Overwrite"
336 </button>
337 </div>
338 </div>
339 </div>
340 }
341}
342
343// ---------------------------------------------------------------------------
344// Picker (move / copy destination)
345// ---------------------------------------------------------------------------
346
347#[component]
348fn PickerDialog(
349 op: Op,
350 entry: Entry,
351 roots: Vec<RootInfo>,
352 source_root: i64,
353 source_dir: String,
354 on_pick: Callback<(i64, String)>,
355 on_close: Callback<()>,
356) -> impl IntoView {
357 let (sel_root, set_sel_root) = signal(source_root);
358 let (sel_dir, set_sel_dir) = signal(source_dir.clone());
359 let (entries, set_entries) = signal(Vec::<Entry>::new());
360
361 // Load the subfolders of the selected directory whenever it changes.
362 {
363 let root_sig = sel_root;
364 let dir_sig = sel_dir;
365 let set_ent = set_entries;
366 Effect::new(move |_| {
367 let rid = root_sig.get();
368 let dir = dir_sig.get();
369 set_ent.set(Vec::new());
370 spawn_local(async move {
371 match api::list_files(rid, &dir).await {
372 Ok(r) => set_ent.set(
373 r.entries
374 .into_iter()
375 .filter(|e| e.is_dir)
376 .collect(),
377 ),
378 Err(_) => set_ent.set(Vec::new()),
379 }
380 });
381 });
382 }
383
384 let sel_root2 = sel_root;
385 let sel_dir2 = sel_dir;
386 let set_dir = set_sel_dir;
387 let roots_by_id = roots.clone();
388 view! {
389 <div class="modal-overlay" on:click=move |_| on_close.run(())>
390 <div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
391 <h2 class="modal-title">
392 {move || format!("{} “{}” to…", op.verb(), entry.name)}
393 </h2>
394
395 <div class="picker-roots">
396 {roots.iter().map(|r| {
397 let rid = r.id;
398 let ro = r.mode == "ro";
399 let name = if ro {
400 format!("{} (read-only)", r.name)
401 } else {
402 r.name.clone()
403 };
404 view! {
405 <button
406 class=move || {
407 if rid == sel_root2.get() {
408 "btn root-opt active".to_string()
409 } else {
410 "btn root-opt".to_string()
411 }
412 }
413 on:click=move |_| {
414 set_sel_root.set(rid);
415 set_sel_dir.set(String::new());
416 }
417 >
418 {name}
419 </button>
420 }
421 }).collect::<Vec<_>>()}
422 </div>
423
424 <p class="picker-current muted">
425 {move || {
426 let rid = sel_root2.get();
427 let rname = roots_by_id
428 .iter()
429 .find(|r| r.id == rid)
430 .map(|r| r.name.clone())
431 .unwrap_or_default();
432 let dir = sel_dir2.get();
433 if dir.is_empty() {
434 format!("{rname}/")
435 } else {
436 format!("{rname}/{dir}")
437 }
438 }}
439 </p>
440
441 // Breadcrumbs within the current directory.
442 <div class="crumbs picker-crumbs">
443 <span
444 class="crumb"
445 on:click=move |_| set_dir.set(String::new())
446 >
447 "top"
448 </span>
449 {move || {
450 let segs = dir_segs(&sel_dir2.get());
451 segs
452 .iter()
453 .enumerate()
454 .map(|(i, seg)| {
455 let seg = seg.clone();
456 let prefix: String = segs[..=i].join("/");
457 let is_last = i == segs.len() - 1;
458 view! {
459 <span class="crumb-sep">"/"</span>
460 <span
461 class=move || {
462 if is_last {
463 "crumb current".to_string()
464 } else {
465 "crumb".to_string()
466 }
467 }
468 on:click=move |_| set_dir.set(prefix.clone())
469 >
470 {seg}
471 </span>
472 }
473 })
474 .collect::<Vec<_>>()
475 }}
476 </div>
477
478 <div class="picker-entries">
479 {move || {
480 let es = entries.get();
481 if es.is_empty() {
482 view! {
483 <p class="muted center-note">
484 "No subfolders — the file will be placed in the current folder."
485 </p>
486 }
487 .into_view()
488 .into_any()
489 } else {
490 es.iter()
491 .map(|e| {
492 let name = e.name.clone();
493 let name2 = name.clone();
494 view! {
495 <div
496 class="picker-row"
497 on:click=move |_| {
498 let cur = sel_dir2.get();
499 let newdir = if cur.is_empty() {
500 name2.clone()
501 } else {
502 format!("{cur}/{name2}")
503 };
504 set_dir.set(newdir);
505 }
506 >
507 <Icon name=IconName::Folder class=Some("row-icon".into())/>
508 <span class="row-name">{name}</span>
509 </div>
510 }
511 })
512 .collect::<Vec<_>>()
513 .into_view()
514 .into_any()
515 }
516 }}
517 </div>
518
519 <div class="modal-actions">
520 <button class="btn" on:click=move |_| on_close.run(())>
521 "Cancel"
522 </button>
523 <button
524 class="btn btn-primary"
525 disabled=move || {
526 // Moving/copying into the exact same spot is a no-op.
527 sel_root2.get() == source_root && sel_dir2.get() == source_dir
528 }
529 on:click=move |_| {
530 let rid = sel_root2.get();
531 let dir = sel_dir2.get();
532 on_pick.run((rid, dir));
533 on_close.run(());
534 }
535 >
536 {move || format!("{} here", op.verb())}
537 </button>
538 </div>
539 </div>
540 </div>
541 }
542}
543