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;
8
9use crate::api::{self, Entry, RootInfo};
10use crate::components::icon::Icon;
11use crate::components::modal::Modal;
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 is the <dialog>'s own; see `Modal`.
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 title,
226 confirm,
227 roots,
228 root,
229 dir,
230 for_write,
231 on_pick,
232 } => {
233 view! {
234 <PickerDialog
235 title=title.clone()
236 confirm=confirm.clone()
237 roots=roots.clone()
238 root=*root
239 dir=dir.clone()
240 for_write=*for_write
241 on_pick=*on_pick
242 on_close=close
243 />
244 }
245 .into_view()
246 .into_any()
247 }
248 Dialog::DownloadFormat {
249 name,
250 root_id,
251 path,
252 } => {
253 let (name, root_id, path) = (
254 name.clone(),
255 *root_id,
256 path.clone(),
257 );
258 view! {
259 <DownloadFormatDialog
260 name=name
261 root_id=root_id
262 path=path
263 on_close=close
264 />
265 }
266 .into_view()
267 .into_any()
268 }
269 Dialog::Info {
270 entry,
271 root_name,
272 rel_path,
273 } => {
274 let (entry, root_name, rel_path) = (
275 entry.clone(),
276 root_name.clone(),
277 rel_path.clone(),
278 );
279 view! {
280 <InfoDialog
281 entry=entry
282 root_name=root_name
283 rel_path=rel_path
284 on_close=close
285 />
286 }
287 .into_view()
288 .into_any()
289 }
290 Dialog::Share {
291 name,
292 root_id,
293 path,
294 allow_writable,
295 root_writable,
296 } => {
297 let (name, root_id, path, allow_writable, root_writable) = (
298 name.clone(),
299 *root_id,
300 path.clone(),
301 *allow_writable,
302 *root_writable,
303 );
304 view! {
305 <crate::views::shares::ShareDialog
306 name=name
307 root_id=root_id
308 path=path
309 allow_writable=allow_writable
310 root_writable=root_writable
311 close=close
312 />
313 }
314 .into_view()
315 .into_any()
316 }
317 }
318 }}
319 }
320}
321
322// ---------------------------------------------------------------------------
323// Prompt (text input running an async operation)
324// ---------------------------------------------------------------------------
325
326#[component]
327fn PromptOpDialog(
328 title: String,
329 label: String,
330 initial: String,
331 submit: String,
332 run: Callback<String, PromptOpFuture>,
333 on_ok: Callback<()>,
334 on_close: Callback<()>,
335) -> impl IntoView {
336 let input_id = "fbng-prompt-op-input";
337 let (error, set_error) = signal(Option::<String>::None);
338 let (busy, set_busy) = signal(false);
339 // The closure captures only Copy values, so the Enter-key and button
340 // handlers can share it directly.
341 let submit_fn = move || {
342 let name = api::input_value(input_id).trim().to_string();
343 if name.is_empty() || busy.get() {
344 return;
345 }
346 set_busy.set(true);
347 set_error.set(None);
348 let fut = run.run(name);
349 spawn_local(async move {
350 match fut.await {
351 Ok(()) => {
352 on_ok.run(());
353 on_close.run(());
354 }
355 Err(msg) => {
356 set_error.set(Some(msg));
357 set_busy.set(false);
358 }
359 }
360 });
361 };
362 view! {
363 <Modal class="card" on_close=on_close>
364 <h2 class="modal-title">{title}</h2>
365 <label class="modal-label" for=input_id>{label}</label>
366 <input
367 id=input_id
368 class="input"
369 value=initial
370 autofocus=true
371 on:input=move |_| set_error.set(None)
372 on:keydown=move |e: web_sys::KeyboardEvent| {
373 if e.key() == "Enter" {
374 e.prevent_default();
375 submit_fn();
376 }
377 }
378 />
379 {move || {
380 let Some(err) = error.get() else {
381 return view! {}.into_any();
382 };
383 view! { <p class="dialog-error">{err}</p> }
384 .into_view()
385 .into_any()
386 }}
387 <div class="modal-actions">
388 <button class="btn" on:click=move |_| on_close.run(())>
389 {i18n::tr(i18n::k::CANCEL)}
390 </button>
391 <button
392 class="btn btn-primary"
393 disabled=move || busy.get()
394 on:click=move |_| submit_fn()
395 >
396 {submit}
397 </button>
398 </div>
399 </Modal>
400 }
401}
402
403// ---------------------------------------------------------------------------
404// Confirm
405// ---------------------------------------------------------------------------
406
407#[component]
408fn ConfirmDialog(
409 title: String,
410 message: String,
411 submit: String,
412 danger: bool,
413 on_submit: Callback<()>,
414 on_close: Callback<()>,
415) -> impl IntoView {
416 view! {
417 <Modal class="card" on_close=on_close>
418 <h2 class="modal-title">{title}</h2>
419 <p class="modal-message">{message}</p>
420 <div class="modal-actions">
421 <button class="btn" on:click=move |_| on_close.run(())>
422 {i18n::tr(i18n::k::CANCEL)}
423 </button>
424 <button
425 class=move || {
426 if danger {
427 "btn btn-danger".to_string()
428 } else {
429 "btn btn-primary".to_string()
430 }
431 }
432 on:click=move |_| {
433 on_submit.run(());
434 on_close.run(());
435 }
436 >
437 {submit}
438 </button>
439 </div>
440 </Modal>
441 }
442}
443
444// ---------------------------------------------------------------------------
445// Conflict (overwrite warning)
446// ---------------------------------------------------------------------------
447
448#[component]
449fn ConflictDialog(
450 title: String,
451 files: Vec<String>,
452 on_submit: Callback<()>,
453 on_close: Callback<()>,
454) -> impl IntoView {
455 let shown: Vec<String> = files.iter().take(20).cloned().collect();
456 let extra = files.len().saturating_sub(shown.len());
457 view! {
458 <Modal class="card" on_close=on_close>
459 <h2 class="modal-title">{title}</h2>
460 <p class="modal-message">
461 {i18n::tr(i18n::k::OVERWRITE_QUESTION)}
462 </p>
463 <ul class="conflict-list">
464 {shown.iter().map(|f| {
465 let f = f.clone();
466 view! { <li>{f}</li> }
467 }).collect::<Vec<_>>()}
468 {move || {
469 if extra > 0 {
470 view! { <li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li> }
471 .into_view()
472 .into_any()
473 } else {
474 view! {}.into_any()
475 }
476 }}
477 </ul>
478 <div class="modal-actions">
479 <button class="btn" on:click=move |_| on_close.run(())>
480 {i18n::tr(i18n::k::CANCEL)}
481 </button>
482 <button
483 class="btn btn-danger"
484 on:click=move |_| {
485 on_submit.run(());
486 on_close.run(());
487 }
488 >
489 {i18n::tr(i18n::k::OVERWRITE)}
490 </button>
491 </div>
492 </Modal>
493 }
494}
495
496// ---------------------------------------------------------------------------
497// Picker (folder chooser)
498// ---------------------------------------------------------------------------
499
500/// The one entry of the admin picker's root pane: the server root, which
501/// every admin can browse read-only under [`api_types::ADMIN_ROOT`].
502pub fn admin_root() -> RootInfo {
503 RootInfo {
504 id: api_types::ADMIN_ROOT,
505 name: i18n::t(i18n::k::SERVER_ROOT).to_string(),
506 path: ".".to_string(),
507 mode: api_types::Mode::Rw,
508 }
509}
510
511/// Two panes: the roots on the left, the folder tree of the selected root
512/// on the right (breadcrumbs + subfolders). Clicking a subfolder descends;
513/// the breadcrumbs go back up and name the chosen folder.
514#[component]
515fn PickerDialog(
516 title: String,
517 confirm: String,
518 roots: Vec<RootInfo>,
519 root: i64,
520 dir: String,
521 for_write: bool,
522 on_pick: Callback<(i64, String)>,
523 on_close: Callback<()>,
524) -> impl IntoView {
525 let (sel_root, set_sel_root) = signal(root);
526 let (sel_dir, set_sel_dir) = signal(dir.clone());
527 let (entries, set_entries) = signal(Vec::<String>::new());
528 let (loading, set_loading) = signal(true);
529 let toast = use_context::<ToastMsg>().expect("toast context");
530
531 // Load the subfolder names of the selected directory whenever it changes.
532 // Bumped per load, so a slow response for a folder the user has already
533 // left is dropped instead of replacing the current one.
534 let load_gen = StoredValue::new(0u64);
535 Effect::new(move |_| {
536 let rid = sel_root.get();
537 let dir = sel_dir.get();
538 set_loading.set(true);
539 load_gen.update_value(|g| *g += 1);
540 let my_gen = load_gen.get_value();
541 spawn_local(async move {
542 let resp = api::list_files(rid, &dir).await;
543 if load_gen.try_get_value() != Some(my_gen) {
544 return;
545 }
546 let subs = match resp {
547 Ok(r) => r
548 .entries
549 .into_iter()
550 .filter(|e| e.is_dir)
551 .map(|e| e.name)
552 .collect(),
553 Err(e) => {
554 show_error(toast, e.to_string());
555 Vec::new()
556 }
557 };
558 set_entries.set(subs);
559 set_loading.set(false);
560 });
561 });
562
563 let by_id = Arc::new(roots.clone());
564 let root_name = {
565 let by_id = by_id.clone();
566 move |rid: i64| {
567 by_id
568 .iter()
569 .find(|r| r.id == rid)
570 .map(|r| r.name.clone())
571 .unwrap_or_default()
572 }
573 };
574 let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable());
575 let descend = move |name: String| {
576 let cur = sel_dir.get();
577 set_sel_dir.set(if cur.is_empty() {
578 name
579 } else {
580 format!("{cur}/{name}")
581 });
582 };
583
584 view! {
585 <Modal class="card picker" on_close=on_close>
586 <h2 class="modal-title">{title}</h2>
587
588 <div class="picker-body">
589 <div class="picker-roots">
590 {roots.iter().map(|r| {
591 let rid = r.id;
592 let ro = !r.mode.is_writable();
593 let name = r.name.clone();
594 view! {
595 <button
596 class="picker-root"
597 class:active=move || rid == sel_root.get()
598 class:ro=ro
599 title=move || if ro { i18n::t(i18n::k::READ_ONLY_HINT) } else { "" }
600 on:click=move |_| {
601 set_sel_root.set(rid);
602 set_sel_dir.set(String::new());
603 }
604 >
605 <Icon name=IconName::Folder class="picker-root-ic".to_string()/>
606 <span class="picker-root-name">{name}</span>
607 </button>
608 }
609 }).collect::<Vec<_>>()}
610 </div>
611
612 <div class="picker-main">
613 <div class="crumbs picker-crumbs">
614 <span
615 class="crumb"
616 class:current=move || sel_dir.get().is_empty()
617 on:click=move |_| set_sel_dir.set(String::new())
618 >
619 {move || root_name(sel_root.get())}
620 </span>
621 {move || {
622 let segs = dir_segs(&sel_dir.get());
623 segs
624 .iter()
625 .enumerate()
626 .map(|(i, seg)| {
627 let seg = seg.clone();
628 let prefix: String = segs[..=i].join("/");
629 let is_last = i == segs.len() - 1;
630 view! {
631 <span class="crumb-sep">"/"</span>
632 <span
633 class="crumb"
634 class:current=is_last
635 on:click=move |_| set_sel_dir.set(prefix.clone())
636 >
637 {seg}
638 </span>
639 }
640 })
641 .collect::<Vec<_>>()
642 }}
643 </div>
644
645 <div class="picker-entries">
646 {move || {
647 let es = entries.get();
648 if loading.get() {
649 return view! {
650 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
651 }
652 .into_view()
653 .into_any();
654 }
655 if es.is_empty() {
656 return view! {
657 <div class="picker-empty muted">
658 <Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
659 <span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
660 </div>
661 }
662 .into_view()
663 .into_any();
664 }
665 es.iter()
666 .map(|name| {
667 let name = name.clone();
668 let name2 = name.clone();
669 view! {
670 <div class="picker-row" on:click=move |_| descend(name2.clone())>
671 <Icon name=IconName::Folder class="row-icon".to_string()/>
672 <span class="row-name">{name}</span>
673 <span class="picker-chev"></span>
674 </div>
675 }
676 })
677 .collect::<Vec<_>>()
678 .into_view()
679 .into_any()
680 }}
681 </div>
682 </div>
683 </div>
684
685 <div class="modal-actions">
686 <button class="btn" on:click=move |_| on_close.run(())>
687 {i18n::tr(i18n::k::CANCEL)}
688 </button>
689 <button
690 class="btn btn-primary"
691 disabled=move || {
692 let rid = sel_root.get();
693 for_write && (!root_writable(rid) || (rid == root && sel_dir.get() == dir))
694 }
695 on:click=move |_| {
696 on_pick.run((sel_root.get(), sel_dir.get()));
697 on_close.run(());
698 }
699 >
700 {confirm}
701 </button>
702 </div>
703 </Modal>
704 }
705}
706
707// ---------------------------------------------------------------------------
708// Download format (folder archive choice)
709// ---------------------------------------------------------------------------
710
711#[component]
712fn DownloadFormatDialog(
713 name: String,
714 root_id: i64,
715 path: String,
716 on_close: Callback<()>,
717) -> impl IntoView {
718 let toast = use_context::<ToastMsg>().expect("toast context");
719 let name_title = name.clone();
720 let formats: [(&str, &str, &str); 4] = [
721 ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
722 ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
723 ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
724 ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
725 ];
726 view! {
727 <Modal class="card" on_close=on_close>
728 <h2 class="modal-title">
729 {move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
730 </h2>
731 <p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
732 <div class="dl-formats">
733 {formats.iter().map(|(id, label, ext)| {
734 let (id, label, ext) = (*id, *label, *ext);
735 let path = path.clone();
736 let name = name.clone();
737 view! {
738 <button
739 class="btn btn-primary dl-fmt"
740 on:click=move |_| {
741 let url = api::download_url(root_id, &path, Some(id));
742 let fname = format!("{name}{ext}");
743 api::trigger_download(&url, &fname);
744 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
745 on_close.run(());
746 }
747 >
748 {label}
749 </button>
750 }
751 }).collect::<Vec<_>>()}
752 </div>
753 <div class="modal-actions">
754 <button class="btn" on:click=move |_| on_close.run(())>
755 {i18n::tr(i18n::k::CANCEL)}
756 </button>
757 </div>
758 </Modal>
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 <Modal class="card" on_close=on_close>
789 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
790 <dl class="info-list">
791 <dt>{i18n::tr(i18n::k::NAME)}</dt>
792 <dd>{entry.name.clone()}</dd>
793 <dt>{i18n::tr(i18n::k::TYPE)}</dt>
794 <dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
795 <dt>{i18n::tr(i18n::k::SIZE)}</dt>
796 <dd>{size}</dd>
797 <dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
798 <dd>{modified}</dd>
799 <dt>{i18n::tr(i18n::k::LOCATION)}</dt>
800 <dd>{location}</dd>
801 </dl>
802 <div class="modal-actions">
803 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
804 {i18n::tr(i18n::k::CLOSE)}
805 </button>
806 </div>
807 </Modal>
808 }
809}
810