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 /// Folder download — format choice (zip | tar | tar.gz | tar.zst).
62 DownloadFormat {
63 name: String,
64 root_id: i64,
65 path: String,
66 },
67 /// Read-only item info.
68 Info {
69 entry: Entry,
70 root_name: String,
71 rel_path: String,
72 },
73}
74
75/// Split a "/"-separated directory path into its non-empty segments.
76fn dir_segs(dir: &str) -> Vec<String> {
77 dir.split('/')
78 .filter(|s| !s.is_empty())
79 .map(|s| s.to_string())
80 .collect()
81}
82
83#[component]
84pub fn DialogView(
85 dialog: ReadSignal<Option<Dialog>>,
86 set_dialog: WriteSignal<Option<Dialog>>,
87) -> impl IntoView {
88 view! {
89 {move || {
90 let Some(d) = dialog.get() else {
91 return view! {}.into_any();
92 };
93 let close = Callback::new(move |_| set_dialog.set(None));
94 match &d {
95 Dialog::Prompt {
96 title,
97 label,
98 initial,
99 submit,
100 on_submit,
101 } => {
102 let (title, label, initial, submit, on_submit, close) = (
103 title.clone(),
104 label.clone(),
105 initial.clone(),
106 submit.clone(),
107 on_submit.clone(),
108 close.clone(),
109 );
110 view! {
111 <PromptDialog
112 title=title
113 label=label
114 initial=initial
115 submit=submit
116 on_submit=on_submit
117 on_close=close
118 />
119 }
120 .into_view()
121 .into_any()
122 }
123 Dialog::Confirm {
124 title,
125 message,
126 submit,
127 danger,
128 on_submit,
129 } => {
130 let (title, message, submit, danger, on_submit, close) = (
131 title.clone(),
132 message.clone(),
133 submit.clone(),
134 *danger,
135 on_submit.clone(),
136 close.clone(),
137 );
138 view! {
139 <ConfirmDialog
140 title=title
141 message=message
142 submit=submit
143 danger=danger
144 on_submit=on_submit
145 on_close=close
146 />
147 }
148 .into_view()
149 .into_any()
150 }
151 Dialog::Conflict {
152 title,
153 files,
154 on_submit,
155 } => {
156 let (title, files, on_submit, close) = (
157 title.clone(),
158 files.clone(),
159 on_submit.clone(),
160 close.clone(),
161 );
162 view! {
163 <ConflictDialog
164 title=title
165 files=files
166 on_submit=on_submit
167 on_close=close
168 />
169 }
170 .into_view()
171 .into_any()
172 }
173 Dialog::Picker {
174 op,
175 entry,
176 roots,
177 source_root,
178 source_dir,
179 on_pick,
180 } => {
181 let (op, entry, roots, source_root, source_dir, on_pick, close) = (
182 *op,
183 entry.clone(),
184 roots.clone(),
185 *source_root,
186 source_dir.clone(),
187 on_pick.clone(),
188 close.clone(),
189 );
190 view! {
191 <PickerDialog
192 op=op
193 entry=entry
194 roots=roots
195 source_root=source_root
196 source_dir=source_dir
197 on_pick=on_pick
198 on_close=close
199 />
200 }
201 .into_view()
202 .into_any()
203 }
204 Dialog::DownloadFormat {
205 name,
206 root_id,
207 path,
208 } => {
209 let (name, root_id, path) = (
210 name.clone(),
211 *root_id,
212 path.clone(),
213 );
214 view! {
215 <DownloadFormatDialog
216 name=name
217 root_id=root_id
218 path=path
219 on_close=close.clone()
220 />
221 }
222 .into_view()
223 .into_any()
224 }
225 Dialog::Info {
226 entry,
227 root_name,
228 rel_path,
229 } => {
230 let (entry, root_name, rel_path) = (
231 entry.clone(),
232 root_name.clone(),
233 rel_path.clone(),
234 );
235 view! {
236 <InfoDialog
237 entry=entry
238 root_name=root_name
239 rel_path=rel_path
240 on_close=close.clone()
241 />
242 }
243 .into_view()
244 .into_any()
245 }
246 }
247 }}
248 }
249}
250
251// ---------------------------------------------------------------------------
252// Prompt (text input)
253// ---------------------------------------------------------------------------
254
255#[component]
256fn PromptDialog(
257 title: String,
258 label: String,
259 initial: String,
260 submit: String,
261 on_submit: Callback<String>,
262 on_close: Callback<()>,
263) -> impl IntoView {
264 let input_id = "fbng-prompt-input";
265 let id = input_id.to_string();
266 let id2 = id.clone();
267 let submit_cb = on_submit;
268 view! {
269 <div class="modal-overlay" on:click=move |_| on_close.run(())>
270 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
271 <h2 class="modal-title">{title}</h2>
272 <label class="modal-label" for=input_id>{label}</label>
273 <input
274 id=input_id
275 class="input"
276 value=initial
277 on:keydown=move |e: web_sys::KeyboardEvent| {
278 if e.key() == "Enter" {
279 e.prevent_default();
280 submit_cb.run(api::input_value(&id));
281 }
282 }
283 />
284 <div class="modal-actions">
285 <button class="btn" on:click=move |_| on_close.run(())>
286 "Cancel"
287 </button>
288 <button
289 class="btn btn-primary"
290 on:click=move |_| submit_cb.run(api::input_value(&id2))
291 >
292 {submit}
293 </button>
294 </div>
295 </div>
296 </div>
297 }
298}
299
300// ---------------------------------------------------------------------------
301// Confirm
302// ---------------------------------------------------------------------------
303
304#[component]
305fn ConfirmDialog(
306 title: String,
307 message: String,
308 submit: String,
309 danger: bool,
310 on_submit: Callback<()>,
311 on_close: Callback<()>,
312) -> impl IntoView {
313 view! {
314 <div class="modal-overlay" on:click=move |_| on_close.run(())>
315 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
316 <h2 class="modal-title">{title}</h2>
317 <p class="modal-message">{message}</p>
318 <div class="modal-actions">
319 <button class="btn" on:click=move |_| on_close.run(())>
320 "Cancel"
321 </button>
322 <button
323 class=move || {
324 if danger {
325 "btn btn-danger".to_string()
326 } else {
327 "btn btn-primary".to_string()
328 }
329 }
330 on:click=move |_| {
331 on_submit.run(());
332 on_close.run(());
333 }
334 >
335 {submit}
336 </button>
337 </div>
338 </div>
339 </div>
340 }
341}
342
343// ---------------------------------------------------------------------------
344// Conflict (overwrite warning)
345// ---------------------------------------------------------------------------
346
347#[component]
348fn ConflictDialog(
349 title: String,
350 files: Vec<String>,
351 on_submit: Callback<()>,
352 on_close: Callback<()>,
353) -> impl IntoView {
354 let shown: Vec<String> = files.iter().take(20).cloned().collect();
355 let extra = files.len().saturating_sub(shown.len());
356 view! {
357 <div class="modal-overlay" on:click=move |_| on_close.run(())>
358 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
359 <h2 class="modal-title">{title}</h2>
360 <p class="modal-message">
361 "The following files already exist. Overwrite them?"
362 </p>
363 <ul class="conflict-list">
364 {shown.iter().map(|f| {
365 let f = f.clone();
366 view! { <li>{f}</li> }
367 }).collect::<Vec<_>>()}
368 {move || {
369 if extra > 0 {
370 view! { <li class="muted">format!("…and {extra} more") </li> }
371 .into_view()
372 .into_any()
373 } else {
374 view! {}.into_any()
375 }
376 }}
377 </ul>
378 <div class="modal-actions">
379 <button class="btn" on:click=move |_| on_close.run(())>
380 "Cancel"
381 </button>
382 <button
383 class="btn btn-danger"
384 on:click=move |_| {
385 on_submit.run(());
386 on_close.run(());
387 }
388 >
389 "Overwrite"
390 </button>
391 </div>
392 </div>
393 </div>
394 }
395}
396
397// ---------------------------------------------------------------------------
398// Picker (move / copy destination)
399// ---------------------------------------------------------------------------
400
401#[component]
402fn PickerDialog(
403 op: Op,
404 entry: Entry,
405 roots: Vec<RootInfo>,
406 source_root: i64,
407 source_dir: String,
408 on_pick: Callback<(i64, String)>,
409 on_close: Callback<()>,
410) -> impl IntoView {
411 let (sel_root, set_sel_root) = signal(source_root);
412 let (sel_dir, set_sel_dir) = signal(source_dir.clone());
413 let (entries, set_entries) = signal(Vec::<Entry>::new());
414
415 // Load the subfolders of the selected directory whenever it changes.
416 {
417 let root_sig = sel_root;
418 let dir_sig = sel_dir;
419 let set_ent = set_entries;
420 Effect::new(move |_| {
421 let rid = root_sig.get();
422 let dir = dir_sig.get();
423 set_ent.set(Vec::new());
424 spawn_local(async move {
425 match api::list_files(rid, &dir).await {
426 Ok(r) => set_ent.set(
427 r.entries
428 .into_iter()
429 .filter(|e| e.is_dir)
430 .collect(),
431 ),
432 Err(_) => set_ent.set(Vec::new()),
433 }
434 });
435 });
436 }
437
438 let sel_root2 = sel_root;
439 let sel_dir2 = sel_dir;
440 let set_dir = set_sel_dir;
441 let roots_by_id = roots.clone();
442 view! {
443 <div class="modal-overlay" on:click=move |_| on_close.run(())>
444 <div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
445 <h2 class="modal-title">
446 {move || format!("{} “{}” to…", op.verb(), entry.name)}
447 </h2>
448
449 <div class="picker-roots">
450 {roots.iter().map(|r| {
451 let rid = r.id;
452 let ro = r.mode == "ro";
453 let name = if ro {
454 format!("{} (read-only)", r.name)
455 } else {
456 r.name.clone()
457 };
458 view! {
459 <button
460 class=move || {
461 if rid == sel_root2.get() {
462 "btn root-opt active".to_string()
463 } else {
464 "btn root-opt".to_string()
465 }
466 }
467 on:click=move |_| {
468 set_sel_root.set(rid);
469 set_sel_dir.set(String::new());
470 }
471 >
472 {name}
473 </button>
474 }
475 }).collect::<Vec<_>>()}
476 </div>
477
478 <p class="picker-current muted">
479 {move || {
480 let rid = sel_root2.get();
481 let rname = roots_by_id
482 .iter()
483 .find(|r| r.id == rid)
484 .map(|r| r.name.clone())
485 .unwrap_or_default();
486 let dir = sel_dir2.get();
487 if dir.is_empty() {
488 format!("{rname}/")
489 } else {
490 format!("{rname}/{dir}")
491 }
492 }}
493 </p>
494
495 // Breadcrumbs within the current directory.
496 <div class="crumbs picker-crumbs">
497 <span
498 class="crumb"
499 on:click=move |_| set_dir.set(String::new())
500 >
501 "top"
502 </span>
503 {move || {
504 let segs = dir_segs(&sel_dir2.get());
505 segs
506 .iter()
507 .enumerate()
508 .map(|(i, seg)| {
509 let seg = seg.clone();
510 let prefix: String = segs[..=i].join("/");
511 let is_last = i == segs.len() - 1;
512 view! {
513 <span class="crumb-sep">"/"</span>
514 <span
515 class=move || {
516 if is_last {
517 "crumb current".to_string()
518 } else {
519 "crumb".to_string()
520 }
521 }
522 on:click=move |_| set_dir.set(prefix.clone())
523 >
524 {seg}
525 </span>
526 }
527 })
528 .collect::<Vec<_>>()
529 }}
530 </div>
531
532 <div class="picker-entries">
533 {move || {
534 let es = entries.get();
535 if es.is_empty() {
536 view! {
537 <p class="muted center-note">
538 "No subfolders — the file will be placed in the current folder."
539 </p>
540 }
541 .into_view()
542 .into_any()
543 } else {
544 es.iter()
545 .map(|e| {
546 let name = e.name.clone();
547 let name2 = name.clone();
548 view! {
549 <div
550 class="picker-row"
551 on:click=move |_| {
552 let cur = sel_dir2.get();
553 let newdir = if cur.is_empty() {
554 name2.clone()
555 } else {
556 format!("{cur}/{name2}")
557 };
558 set_dir.set(newdir);
559 }
560 >
561 <Icon name=IconName::Folder class=Some("row-icon".into())/>
562 <span class="row-name">{name}</span>
563 </div>
564 }
565 })
566 .collect::<Vec<_>>()
567 .into_view()
568 .into_any()
569 }
570 }}
571 </div>
572
573 <div class="modal-actions">
574 <button class="btn" on:click=move |_| on_close.run(())>
575 "Cancel"
576 </button>
577 <button
578 class="btn btn-primary"
579 disabled=move || {
580 // Moving/copying into the exact same spot is a no-op.
581 sel_root2.get() == source_root && sel_dir2.get() == source_dir
582 }
583 on:click=move |_| {
584 let rid = sel_root2.get();
585 let dir = sel_dir2.get();
586 on_pick.run((rid, dir));
587 on_close.run(());
588 }
589 >
590 {move || format!("{} here", op.verb())}
591 </button>
592 </div>
593 </div>
594 </div>
595 }
596}
597
598// ---------------------------------------------------------------------------
599// Download format (folder archive choice)
600// ---------------------------------------------------------------------------
601
602#[component]
603fn DownloadFormatDialog(
604 name: String,
605 root_id: i64,
606 path: String,
607 on_close: Callback<()>,
608) -> impl IntoView {
609 let formats: [(&str, &str, &str); 4] = [
610 ("zip", "Zip", ".zip"),
611 ("tar", "Tar", ".tar"),
612 ("tar.gz", "Tar + gzip", ".tar.gz"),
613 ("tar.zst", "Tar + zstd", ".tar.zst"),
614 ];
615 view! {
616 <div class="modal-overlay" on:click=move |_| on_close.run(())>
617 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
618 <h2 class="modal-title">{format!("Download “{name}”")}</h2>
619 <p class="modal-message">"Choose an archive format for the folder."</p>
620 <div class="dl-formats">
621 {formats.iter().map(|(id, label, ext)| {
622 let (id, label, ext) = (*id, *label, *ext);
623 let root_id = root_id;
624 let path = path.clone();
625 let name = name.clone();
626 let on_close = on_close.clone();
627 view! {
628 <button
629 class="btn btn-primary dl-fmt"
630 on:click=move |_| {
631 let url = api::download_url(root_id, &path, Some(id));
632 let fname = format!("{name}{ext}");
633 api::trigger_download(&url, &fname);
634 on_close.run(());
635 }
636 >
637 {label}
638 </button>
639 }
640 }).collect::<Vec<_>>()}
641 </div>
642 <div class="modal-actions">
643 <button class="btn" on:click=move |_| on_close.run(())>
644 "Cancel"
645 </button>
646 </div>
647 </div>
648 </div>
649 }
650}
651
652// ---------------------------------------------------------------------------
653// Item info
654// ---------------------------------------------------------------------------
655
656#[component]
657fn InfoDialog(
658 entry: Entry,
659 root_name: String,
660 rel_path: String,
661 on_close: Callback<()>,
662) -> impl IntoView {
663 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
664 let is_dir = entry.is_dir;
665 let size = if is_dir {
666 "—".to_string()
667 } else {
668 crate::util::format_size(entry.size)
669 };
670 let modified = crate::util::format_date(&entry.mtime);
671 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
672 let location = if parent.is_empty() {
673 format!("{root_name}/")
674 } else {
675 format!("{root_name}/{parent}")
676 };
677 view! {
678 <div class="modal-overlay" on:click=move |_| on_close.run(())>
679 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
680 <h2 class="modal-title">"File info"</h2>
681 <dl class="info-list">
682 <dt>"Name"</dt>
683 <dd>{entry.name.clone()}</dd>
684 <dt>"Type"</dt>
685 <dd>{if is_dir { "Folder".to_string() } else { "File".to_string() }}</dd>
686 <dt>"Size"</dt>
687 <dd>{size}</dd>
688 <dt>"Modified"</dt>
689 <dd>{modified}</dd>
690 <dt>"Location"</dt>
691 <dd>{location}</dd>
692 </dl>
693 <div class="modal-actions">
694 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
695 "Close"
696 </button>
697 </div>
698 </div>
699 </div>
700 }
701}
702