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