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