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