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