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 /// Upload pre-check found existing files. Overwrite or skip them, and
78 /// optionally make that the rule for files that appear mid-transfer.
79 UploadConflict {
80 files: Vec<String>,
81 /// `(overwrite, apply_to_all)`.
82 /// `None` when the user cancelled (button, Escape, backdrop).
83 on_decide: Callback<Option<(bool, bool)>>,
84 },
85 /// Folder picker: the move/copy destination, or where a search starts.
86 Picker {
87 title: String,
88 /// The confirm button label ("Move here", "Search here").
89 confirm: String,
90 roots: Vec<RootInfo>,
91 /// The folder the picker opens in.
92 root: i64,
93 dir: String,
94 /// The picked folder is written to (move/copy destination): only a
95 /// writable root can be confirmed, and not the folder the picker
96 /// opened in (a move or copy onto itself is a no-op).
97 for_write: bool,
98 on_pick: Callback<(i64, String)>,
99 },
100 /// Folder download — format choice (zip | tar | tar.gz | tar.zst).
101 DownloadFormat {
102 name: String,
103 root_id: i64,
104 path: String,
105 },
106 /// Read-only item info.
107 Info {
108 entry: Entry,
109 root_name: String,
110 rel_path: String,
111 },
112 /// Import an `.ics` or `.vcf` file into a calendar or address book.
113 PimImport {
114 root_id: i64,
115 path: String,
116 name: String,
117 },
118 /// Create a share link for an item.
119 Share {
120 name: String,
121 root_id: i64,
122 path: String,
123 /// The server's global "allow writable shares" setting.
124 allow_writable: bool,
125 /// Whether the item's root is writable — a writable share is
126 /// impossible from a read-only root, so the checkbox hides.
127 root_writable: bool,
128 },
129}
130
131/// The future returned by [`Dialog::PromptOp`]'s `run` callback.
132pub type PromptOpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
133
134/// Split a "/"-separated directory path into its non-empty segments.
135pub(crate) fn dir_segs(dir: &str) -> Vec<String> {
136 dir.split('/')
137 .filter(|s| !s.is_empty())
138 .map(|s| s.to_string())
139 .collect()
140}
141
142/// Wait until the shell's one dialog slot is free. A background task
143/// (upload or move conflict) must not replace a dialog that is showing,
144/// whether another task or the user opened it.
145///
146/// ponytail: 200 ms poll, wake channel if latency matters.
147pub async fn claim_async_dialog(dialog: ReadSignal<Option<Dialog>>) {
148 while dialog.get_untracked().is_some() {
149 gloo_timers::future::sleep(std::time::Duration::from_millis(200)).await;
150 }
151}
152
153#[component]
154pub fn DialogView(
155 dialog: ReadSignal<Option<Dialog>>,
156 set_dialog: WriteSignal<Option<Dialog>>,
157) -> impl IntoView {
158 // Escape is the <dialog>'s own; see `Modal`.
159 move || {
160 let d = dialog.get()?;
161 let close = Callback::new(move |_| set_dialog.set(None));
162 Some(match d {
163 Dialog::PromptOp {
164 title,
165 label,
166 initial,
167 submit,
168 run,
169 on_ok,
170 } => view! {
171 <PromptOpDialog
172 title=title
173 label=label
174 initial=initial
175 submit=submit
176 run=run
177 on_ok=on_ok
178 on_close=close
179 />
180 }
181 .into_any(),
182 Dialog::Confirm {
183 title,
184 message,
185 submit,
186 danger,
187 on_submit,
188 } => view! {
189 <ConfirmDialog
190 title=title
191 message=message
192 submit=submit
193 danger=danger
194 on_submit=on_submit
195 on_close=close
196 />
197 }
198 .into_any(),
199 Dialog::UploadConflict { files, on_decide } => view! {
200 <UploadConflictDialog files=files on_decide=on_decide on_close=close/>
201 }
202 .into_any(),
203 Dialog::Conflict {
204 title,
205 files,
206 on_submit,
207 } => view! {
208 <ConfirmDialog
209 title=title
210 message=i18n::t(i18n::k::OVERWRITE_QUESTION).to_string()
211 submit=i18n::t(i18n::k::OVERWRITE).to_string()
212 danger=true
213 files=files
214 on_submit=on_submit
215 on_close=close
216 />
217 }
218 .into_any(),
219 Dialog::Picker {
220 title,
221 confirm,
222 roots,
223 root,
224 dir,
225 for_write,
226 on_pick,
227 } => view! {
228 <PickerDialog
229 title=title
230 confirm=confirm
231 roots=roots
232 root=root
233 dir=dir
234 for_write=for_write
235 on_pick=on_pick
236 on_close=close
237 />
238 }
239 .into_any(),
240 Dialog::DownloadFormat {
241 name,
242 root_id,
243 path,
244 } => view! {
245 <DownloadFormatDialog name=name root_id=root_id path=path on_close=close/>
246 }
247 .into_any(),
248 Dialog::Info {
249 entry,
250 root_name,
251 rel_path,
252 } => view! {
253 <InfoDialog entry=entry root_name=root_name rel_path=rel_path on_close=close/>
254 }
255 .into_any(),
256 Dialog::PimImport {
257 root_id,
258 path,
259 name,
260 } => view! {
261 <crate::views::pim::PimImportDialog
262 root_id=root_id
263 path=path
264 name=name
265 on_close=close
266 />
267 }
268 .into_any(),
269 Dialog::Share {
270 name,
271 root_id,
272 path,
273 allow_writable,
274 root_writable,
275 } => view! {
276 <crate::views::shares::ShareDialog
277 name=name
278 root_id=root_id
279 path=path
280 allow_writable=allow_writable
281 root_writable=root_writable
282 close=close
283 />
284 }
285 .into_any(),
286 })
287 }
288}
289
290// ---------------------------------------------------------------------------
291// Prompt (text input running an async operation)
292// ---------------------------------------------------------------------------
293
294#[component]
295fn PromptOpDialog(
296 title: String,
297 label: String,
298 initial: String,
299 submit: String,
300 run: Callback<String, PromptOpFuture>,
301 on_ok: Callback<()>,
302 on_close: Callback<()>,
303) -> impl IntoView {
304 let input_id = "fbng-prompt-op-input";
305 let (error, set_error) = signal(Option::<String>::None);
306 let (busy, set_busy) = signal(false);
307 // The closure captures only Copy values, so the Enter-key and button
308 // handlers can share it directly.
309 let submit_fn = move || {
310 let name = api::input_value(input_id).trim().to_string();
311 if name.is_empty() || busy.get() {
312 return;
313 }
314 set_busy.set(true);
315 set_error.set(None);
316 let fut = run.run(name);
317 spawn_local(async move {
318 match fut.await {
319 Ok(()) => {
320 on_ok.run(());
321 on_close.run(());
322 }
323 Err(msg) => {
324 set_error.set(Some(msg));
325 set_busy.set(false);
326 }
327 }
328 });
329 };
330 view! {
331 <Modal class="card" on_close=on_close>
332 <h2 class="modal-title">{title}</h2>
333 <label class="modal-label" for=input_id>{label}</label>
334 <input
335 id=input_id
336 class="input"
337 value=initial
338 autofocus=true
339 on:input=move |_| set_error.set(None)
340 on:keydown=move |e: web_sys::KeyboardEvent| {
341 if e.key() == "Enter" {
342 e.prevent_default();
343 submit_fn();
344 }
345 }
346 />
347 {move || error.get().map(|err| view! { <p class="dialog-error">{err}</p> })}
348 <div class="modal-actions">
349 <button class="btn" on:click=move |_| on_close.run(())>
350 {i18n::tr(i18n::k::CANCEL)}
351 </button>
352 <button
353 class="btn btn-primary"
354 disabled=move || busy.get()
355 on:click=move |_| submit_fn()
356 >
357 {submit}
358 </button>
359 </div>
360 </Modal>
361 }
362}
363
364// ---------------------------------------------------------------------------
365// Confirm and conflicts
366// ---------------------------------------------------------------------------
367
368/// A yes/no question. `files` lists the affected names under the message.
369#[component]
370pub fn ConfirmDialog(
371 title: String,
372 message: String,
373 submit: String,
374 danger: bool,
375 #[prop(optional)] files: Vec<String>,
376 /// The dismiss button label. Defaults to "Cancel".
377 #[prop(optional)]
378 cancel: Option<String>,
379 on_submit: Callback<()>,
380 on_close: Callback<()>,
381) -> impl IntoView {
382 let cancel = cancel.unwrap_or_else(|| i18n::t(i18n::k::CANCEL).to_string());
383 view! {
384 <Modal class="card" on_close=on_close>
385 <h2 class="modal-title">{title}</h2>
386 <p class="modal-message">{message}</p>
387 {(!files.is_empty()).then(|| conflict_list(&files))}
388 <div class="modal-actions">
389 <button class="btn" on:click=move |_| on_close.run(())>
390 {cancel}
391 </button>
392 <button
393 class=if danger { "btn btn-danger" } else { "btn btn-primary" }
394 on:click=move |_| {
395 on_submit.run(());
396 on_close.run(());
397 }
398 >
399 {submit}
400 </button>
401 </div>
402 </Modal>
403 }
404}
405
406/// The list of colliding files shown by both conflict dialogs. Long lists
407/// are cut off with an "and N more" line.
408fn conflict_list(files: &[String]) -> impl IntoView + use<> {
409 let shown: Vec<String> = files.iter().take(20).cloned().collect();
410 let extra = files.len() - shown.len();
411 view! {
412 <ul class="conflict-list">
413 {shown.into_iter().map(|f| view! { <li>{f}</li> }).collect::<Vec<_>>()}
414 <Show when=move || { extra > 0 }>
415 <li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li>
416 </Show>
417 </ul>
418 }
419}
420
421/// The upload pre-check dialog: Overwrite, Skip, Cancel, and "apply to all"
422/// (on by default) for files that appear during the transfer.
423#[component]
424fn UploadConflictDialog(
425 files: Vec<String>,
426 on_decide: Callback<Option<(bool, bool)>>,
427 on_close: Callback<()>,
428) -> impl IntoView {
429 let (apply_all, set_apply_all) = signal(true);
430 // The <dialog> close event fires after a decision too; answer once.
431 let answered = StoredValue::new(false);
432 let decide = move |overwrite: bool| {
433 answered.set_value(true);
434 on_decide.run(Some((overwrite, apply_all.get_untracked())));
435 on_close.run(());
436 };
437 let on_close = Callback::new(move |()| {
438 if !answered.get_value() {
439 answered.set_value(true);
440 on_decide.run(None);
441 }
442 on_close.run(());
443 });
444 view! {
445 <Modal class="card" on_close=on_close>
446 <h2 class="modal-title">{i18n::tr(i18n::k::SOME_FILES_EXIST)}</h2>
447 <p class="modal-message">
448 {i18n::tr(i18n::k::UPLOAD_CONFLICT_QUESTION)}
449 </p>
450 {conflict_list(&files)}
451 <label class="upload-conflict-all">
452 <input
453 type="checkbox"
454 checked=move || apply_all.get()
455 on:change=move |ev| set_apply_all.set(event_target_checked(&ev))
456 />
457 {i18n::tr(i18n::k::UPLOAD_APPLY_ALL)}
458 </label>
459 <div class="modal-actions">
460 <button class="btn" on:click=move |_| on_close.run(())>
461 {i18n::tr(i18n::k::CANCEL)}
462 </button>
463 <button class="btn" on:click=move |_| decide(false)>
464 {i18n::tr(i18n::k::UPLOAD_SKIP)}
465 </button>
466 <button class="btn btn-danger" on:click=move |_| decide(true)>
467 {i18n::tr(i18n::k::OVERWRITE)}
468 </button>
469 </div>
470 </Modal>
471 }
472}
473
474// ---------------------------------------------------------------------------
475// Picker (folder chooser)
476// ---------------------------------------------------------------------------
477
478/// The one entry of the admin picker's root pane: the server root, which
479/// every admin can browse read-only under [`api_types::ADMIN_ROOT`].
480pub fn admin_root() -> RootInfo {
481 RootInfo {
482 id: api_types::ADMIN_ROOT,
483 name: i18n::t(i18n::k::SERVER_ROOT).to_string(),
484 path: ".".to_string(),
485 mode: api_types::Mode::Rw,
486 }
487}
488
489/// Two panes: the roots on the left, the folder tree of the selected root
490/// on the right (breadcrumbs + subfolders). Clicking a subfolder descends;
491/// the breadcrumbs go back up and name the chosen folder.
492#[component]
493fn PickerDialog(
494 title: String,
495 confirm: String,
496 roots: Vec<RootInfo>,
497 root: i64,
498 dir: String,
499 for_write: bool,
500 on_pick: Callback<(i64, String)>,
501 on_close: Callback<()>,
502) -> impl IntoView {
503 let (sel_root, set_sel_root) = signal(root);
504 let (sel_dir, set_sel_dir) = signal(dir.clone());
505 let (entries, set_entries) = signal(Vec::<String>::new());
506 let (loading, set_loading) = signal(true);
507 let toast = use_context::<ToastMsg>().expect("toast context");
508
509 // Load the subfolder names of the selected directory whenever it changes.
510 // Bumped per load, so a slow response for a folder the user has already
511 // left is dropped instead of replacing the current one.
512 let load_gen = StoredValue::new(0u64);
513 Effect::new(move |_| {
514 let rid = sel_root.get();
515 let dir = sel_dir.get();
516 set_loading.set(true);
517 load_gen.update_value(|g| *g += 1);
518 let my_gen = load_gen.get_value();
519 spawn_local(async move {
520 let resp = api::list_dirs(rid, &dir).await;
521 if load_gen.try_get_value() != Some(my_gen) {
522 return;
523 }
524 let subs = match resp {
525 Ok(r) => r.entries.into_iter().map(|e| e.name).collect(),
526 Err(e) => {
527 show_error(toast, e.to_string());
528 Vec::new()
529 }
530 };
531 set_entries.set(subs);
532 set_loading.set(false);
533 });
534 });
535
536 let by_id = Arc::new(roots.clone());
537 let root_name = {
538 let by_id = by_id.clone();
539 move |rid: i64| {
540 by_id
541 .iter()
542 .find(|r| r.id == rid)
543 .map(|r| r.name.clone())
544 .unwrap_or_default()
545 }
546 };
547 let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable());
548 let descend = move |name: String| {
549 let cur = sel_dir.get();
550 set_sel_dir.set(if cur.is_empty() {
551 name
552 } else {
553 format!("{cur}/{name}")
554 });
555 };
556
557 view! {
558 <Modal class="card picker" on_close=on_close>
559 <h2 class="modal-title">{title}</h2>
560
561 <div class="picker-body">
562 <div class="picker-roots">
563 {roots.iter().map(|r| {
564 let rid = r.id;
565 let ro = !r.mode.is_writable();
566 let name = r.name.clone();
567 view! {
568 <button
569 class="picker-root"
570 class:active=move || rid == sel_root.get()
571 class:ro=ro
572 title=move || if ro { i18n::t(i18n::k::READ_ONLY_HINT) } else { "" }
573 on:click=move |_| {
574 set_sel_root.set(rid);
575 set_sel_dir.set(String::new());
576 }
577 >
578 <Icon name=IconName::Folder class="picker-root-ic".to_string()/>
579 <span class="picker-root-name">{name}</span>
580 </button>
581 }
582 }).collect::<Vec<_>>()}
583 </div>
584
585 <div class="picker-main">
586 <div class="crumbs picker-crumbs">
587 <span
588 class="crumb"
589 class:current=move || sel_dir.get().is_empty()
590 on:click=move |_| set_sel_dir.set(String::new())
591 >
592 {move || root_name(sel_root.get())}
593 </span>
594 {move || {
595 let segs = dir_segs(&sel_dir.get());
596 segs
597 .iter()
598 .enumerate()
599 .map(|(i, seg)| {
600 let seg = seg.clone();
601 let prefix: String = segs[..=i].join("/");
602 let is_last = i == segs.len() - 1;
603 view! {
604 <span class="crumb-sep">"/"</span>
605 <span
606 class="crumb"
607 class:current=is_last
608 on:click=move |_| set_sel_dir.set(prefix.clone())
609 >
610 {seg}
611 </span>
612 }
613 })
614 .collect::<Vec<_>>()
615 }}
616 </div>
617
618 <div class="picker-entries">
619 {move || {
620 let es = entries.get();
621 if loading.get() {
622 return view! {
623 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
624 }
625 .into_any();
626 }
627 if es.is_empty() {
628 return view! {
629 <div class="picker-empty muted">
630 <Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
631 <span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
632 </div>
633 }
634 .into_any();
635 }
636 es.iter()
637 .map(|name| {
638 let name = name.clone();
639 let name2 = name.clone();
640 view! {
641 <div class="picker-row" on:click=move |_| descend(name2.clone())>
642 <Icon name=IconName::Folder class="row-icon".to_string()/>
643 <span class="row-name">{name}</span>
644 <span class="picker-chev"></span>
645 </div>
646 }
647 })
648 .collect::<Vec<_>>()
649 .into_any()
650 }}
651 </div>
652 </div>
653 </div>
654
655 <div class="modal-actions">
656 <button class="btn" on:click=move |_| on_close.run(())>
657 {i18n::tr(i18n::k::CANCEL)}
658 </button>
659 <button
660 class="btn btn-primary"
661 disabled=move || {
662 let rid = sel_root.get();
663 for_write && (!root_writable(rid) || (rid == root && sel_dir.get() == dir))
664 }
665 on:click=move |_| {
666 on_pick.run((sel_root.get(), sel_dir.get()));
667 on_close.run(());
668 }
669 >
670 {confirm}
671 </button>
672 </div>
673 </Modal>
674 }
675}
676
677// ---------------------------------------------------------------------------
678// Download format (folder archive choice)
679// ---------------------------------------------------------------------------
680
681#[component]
682fn DownloadFormatDialog(
683 name: String,
684 root_id: i64,
685 path: String,
686 on_close: Callback<()>,
687) -> impl IntoView {
688 let toast = use_context::<ToastMsg>().expect("toast context");
689 let name_title = name.clone();
690 let formats: [(&str, &str, &str); 4] = [
691 ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
692 ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
693 ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
694 ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
695 ];
696 view! {
697 <Modal class="card" on_close=on_close>
698 <h2 class="modal-title">
699 {move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
700 </h2>
701 <p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
702 <div class="dl-formats">
703 {formats.iter().map(|(id, label, ext)| {
704 let (id, label, ext) = (*id, *label, *ext);
705 let path = path.clone();
706 let name = name.clone();
707 view! {
708 <button
709 class="btn btn-primary dl-fmt"
710 on:click=move |_| {
711 let url = api::download_url(root_id, &path, Some(id));
712 let fname = format!("{name}{ext}");
713 api::trigger_download(&url, &fname);
714 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
715 on_close.run(());
716 }
717 >
718 {label}
719 </button>
720 }
721 }).collect::<Vec<_>>()}
722 </div>
723 <div class="modal-actions">
724 <button class="btn" on:click=move |_| on_close.run(())>
725 {i18n::tr(i18n::k::CANCEL)}
726 </button>
727 </div>
728 </Modal>
729 }
730}
731
732// ---------------------------------------------------------------------------
733// Item info
734// ---------------------------------------------------------------------------
735
736#[component]
737fn InfoDialog(
738 entry: Entry,
739 root_name: String,
740 rel_path: String,
741 on_close: Callback<()>,
742) -> impl IntoView {
743 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
744 let is_dir = entry.is_dir;
745 let size = if is_dir {
746 "—".to_string()
747 } else {
748 crate::util::format_size(entry.size)
749 };
750 let modified = crate::util::format_date(&entry.mtime);
751 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
752 let location = if parent.is_empty() {
753 format!("{root_name}/")
754 } else {
755 format!("{root_name}/{parent}")
756 };
757 view! {
758 <Modal class="card" on_close=on_close>
759 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
760 <dl class="info-list">
761 <dt>{i18n::tr(i18n::k::NAME)}</dt>
762 <dd>{entry.name.clone()}</dd>
763 <dt>{i18n::tr(i18n::k::TYPE)}</dt>
764 <dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
765 <dt>{i18n::tr(i18n::k::SIZE)}</dt>
766 <dd>{size}</dd>
767 <dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
768 <dd>{modified}</dd>
769 <dt>{i18n::tr(i18n::k::LOCATION)}</dt>
770 <dd>{location}</dd>
771 </dl>
772 <div class="modal-actions">
773 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
774 {i18n::tr(i18n::k::CLOSE)}
775 </button>
776 </div>
777 </Modal>
778 }
779}
780