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