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, show_error};
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/// The one entry of the admin picker's root pane: the server root, which
519/// every admin can browse read-only under [`api_types::ADMIN_ROOT`].
520pub fn admin_root() -> RootInfo {
521 RootInfo {
522 id: api_types::ADMIN_ROOT,
523 name: i18n::t(i18n::k::SERVER_ROOT).to_string(),
524 path: ".".to_string(),
525 mode: api_types::Mode::Rw,
526 }
527}
528
529/// Two panes: the roots on the left, the folder tree of the selected root
530/// on the right (breadcrumbs + subfolders). Clicking a subfolder descends;
531/// the breadcrumbs go back up and name the chosen folder.
532#[component]
533fn PickerDialog(
534 title: String,
535 confirm: String,
536 roots: Vec<RootInfo>,
537 root: i64,
538 dir: String,
539 for_write: bool,
540 on_pick: Callback<(i64, String)>,
541 on_close: Callback<()>,
542) -> impl IntoView {
543 let (sel_root, set_sel_root) = signal(root);
544 let (sel_dir, set_sel_dir) = signal(dir.clone());
545 let (entries, set_entries) = signal(Vec::<String>::new());
546 let (loading, set_loading) = signal(true);
547 let toast = use_context::<ToastMsg>().expect("toast context");
548
549 // Load the subfolder names of the selected directory whenever it changes.
550 Effect::new(move |_| {
551 let rid = sel_root.get();
552 let dir = sel_dir.get();
553 set_loading.set(true);
554 spawn_local(async move {
555 let subs = match api::list_files(rid, &dir).await {
556 Ok(r) => r
557 .entries
558 .into_iter()
559 .filter(|e| e.is_dir)
560 .map(|e| e.name)
561 .collect(),
562 Err(e) => {
563 show_error(toast, e.to_string());
564 Vec::new()
565 }
566 };
567 set_entries.set(subs);
568 set_loading.set(false);
569 });
570 });
571
572 let by_id = Arc::new(roots.clone());
573 let root_name = {
574 let by_id = by_id.clone();
575 move |rid: i64| {
576 by_id
577 .iter()
578 .find(|r| r.id == rid)
579 .map(|r| r.name.clone())
580 .unwrap_or_default()
581 }
582 };
583 let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable());
584 let descend = move |name: String| {
585 let cur = sel_dir.get();
586 set_sel_dir.set(if cur.is_empty() {
587 name
588 } else {
589 format!("{cur}/{name}")
590 });
591 };
592
593 view! {
594 <div class="modal-overlay" on:click=move |_| on_close.run(())>
595 <div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
596 <h2 class="modal-title">{title}</h2>
597
598 <div class="picker-body">
599 <div class="picker-roots">
600 {roots.iter().map(|r| {
601 let rid = r.id;
602 let ro = !r.mode.is_writable();
603 let name = r.name.clone();
604 view! {
605 <button
606 class="picker-root"
607 class:active=move || rid == sel_root.get()
608 class:ro=ro
609 title=move || if ro { i18n::t(i18n::k::READ_ONLY_HINT) } else { "" }
610 on:click=move |_| {
611 set_sel_root.set(rid);
612 set_sel_dir.set(String::new());
613 }
614 >
615 <Icon name=IconName::Folder class="picker-root-ic".to_string()/>
616 <span class="picker-root-name">{name}</span>
617 </button>
618 }
619 }).collect::<Vec<_>>()}
620 </div>
621
622 <div class="picker-main">
623 <div class="crumbs picker-crumbs">
624 <span
625 class="crumb"
626 class:current=move || sel_dir.get().is_empty()
627 on:click=move |_| set_sel_dir.set(String::new())
628 >
629 {move || root_name(sel_root.get())}
630 </span>
631 {move || {
632 let segs = dir_segs(&sel_dir.get());
633 segs
634 .iter()
635 .enumerate()
636 .map(|(i, seg)| {
637 let seg = seg.clone();
638 let prefix: String = segs[..=i].join("/");
639 let is_last = i == segs.len() - 1;
640 view! {
641 <span class="crumb-sep">"/"</span>
642 <span
643 class="crumb"
644 class:current=is_last
645 on:click=move |_| set_sel_dir.set(prefix.clone())
646 >
647 {seg}
648 </span>
649 }
650 })
651 .collect::<Vec<_>>()
652 }}
653 </div>
654
655 <div class="picker-entries">
656 {move || {
657 let es = entries.get();
658 if loading.get() {
659 return view! {
660 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
661 }
662 .into_view()
663 .into_any();
664 }
665 if es.is_empty() {
666 return view! {
667 <div class="picker-empty muted">
668 <Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
669 <span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
670 </div>
671 }
672 .into_view()
673 .into_any();
674 }
675 es.iter()
676 .map(|name| {
677 let name = name.clone();
678 let name2 = name.clone();
679 view! {
680 <div class="picker-row" on:click=move |_| descend(name2.clone())>
681 <Icon name=IconName::Folder class="row-icon".to_string()/>
682 <span class="row-name">{name}</span>
683 <span class="picker-chev"></span>
684 </div>
685 }
686 })
687 .collect::<Vec<_>>()
688 .into_view()
689 .into_any()
690 }}
691 </div>
692 </div>
693 </div>
694
695 <div class="modal-actions">
696 <button class="btn" on:click=move |_| on_close.run(())>
697 {i18n::tr(i18n::k::CANCEL)}
698 </button>
699 <button
700 class="btn btn-primary"
701 disabled=move || {
702 let rid = sel_root.get();
703 for_write && (!root_writable(rid) || (rid == root && sel_dir.get() == dir))
704 }
705 on:click=move |_| {
706 on_pick.run((sel_root.get(), sel_dir.get()));
707 on_close.run(());
708 }
709 >
710 {confirm}
711 </button>
712 </div>
713 </div>
714 </div>
715 }
716}
717
718// ---------------------------------------------------------------------------
719// Download format (folder archive choice)
720// ---------------------------------------------------------------------------
721
722#[component]
723fn DownloadFormatDialog(
724 name: String,
725 root_id: i64,
726 path: String,
727 on_close: Callback<()>,
728) -> impl IntoView {
729 let toast = use_context::<ToastMsg>().expect("toast context");
730 let name_title = name.clone();
731 let formats: [(&str, &str, &str); 4] = [
732 ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
733 ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
734 ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
735 ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
736 ];
737 view! {
738 <div class="modal-overlay" on:click=move |_| on_close.run(())>
739 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
740 <h2 class="modal-title">
741 {move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
742 </h2>
743 <p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
744 <div class="dl-formats">
745 {formats.iter().map(|(id, label, ext)| {
746 let (id, label, ext) = (*id, *label, *ext);
747 let path = path.clone();
748 let name = name.clone();
749 view! {
750 <button
751 class="btn btn-primary dl-fmt"
752 on:click=move |_| {
753 let url = api::download_url(root_id, &path, Some(id));
754 let fname = format!("{name}{ext}");
755 api::trigger_download(&url, &fname);
756 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
757 on_close.run(());
758 }
759 >
760 {label}
761 </button>
762 }
763 }).collect::<Vec<_>>()}
764 </div>
765 <div class="modal-actions">
766 <button class="btn" on:click=move |_| on_close.run(())>
767 {i18n::tr(i18n::k::CANCEL)}
768 </button>
769 </div>
770 </div>
771 </div>
772 }
773}
774
775// ---------------------------------------------------------------------------
776// Item info
777// ---------------------------------------------------------------------------
778
779#[component]
780fn InfoDialog(
781 entry: Entry,
782 root_name: String,
783 rel_path: String,
784 on_close: Callback<()>,
785) -> impl IntoView {
786 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
787 let is_dir = entry.is_dir;
788 let size = if is_dir {
789 "—".to_string()
790 } else {
791 crate::util::format_size(entry.size)
792 };
793 let modified = crate::util::format_date(&entry.mtime);
794 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
795 let location = if parent.is_empty() {
796 format!("{root_name}/")
797 } else {
798 format!("{root_name}/{parent}")
799 };
800 view! {
801 <div class="modal-overlay" on:click=move |_| on_close.run(())>
802 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
803 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
804 <dl class="info-list">
805 <dt>{i18n::tr(i18n::k::NAME)}</dt>
806 <dd>{entry.name.clone()}</dd>
807 <dt>{i18n::tr(i18n::k::TYPE)}</dt>
808 <dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
809 <dt>{i18n::tr(i18n::k::SIZE)}</dt>
810 <dd>{size}</dd>
811 <dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
812 <dd>{modified}</dd>
813 <dt>{i18n::tr(i18n::k::LOCATION)}</dt>
814 <dd>{location}</dd>
815 </dl>
816 <div class="modal-actions">
817 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
818 {i18n::tr(i18n::k::CLOSE)}
819 </button>
820 </div>
821 </div>
822 </div>
823 }
824}
825