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 leptos::prelude::*;
5use wasm_bindgen_futures::spawn_local;
6use web_sys::MouseEvent;
7
8use crate::api::{self, Entry, RootInfo};
9use crate::components::icon::Icon;
10use crate::components::toast::{ToastMsg, show};
11use crate::i18n;
12use crate::icons::IconName;
13
14/// Which mutating operation the destination picker is for.
15#[derive(Clone, Copy, PartialEq)]
16pub enum Op {
17 Move,
18 Copy,
19}
20
21impl Op {
22 /// The translated title prefix for the destination picker ("Move … to…").
23 pub fn title_key(self) -> &'static str {
24 match self {
25 Op::Move => crate::i18n::k::MOVE_TO,
26 Op::Copy => crate::i18n::k::COPY_TO,
27 }
28 }
29
30 /// The translated "… here" confirm button label.
31 pub fn here_key(self) -> &'static str {
32 match self {
33 Op::Move => crate::i18n::k::MOVE_HERE,
34 Op::Copy => crate::i18n::k::COPY_HERE,
35 }
36 }
37}
38
39/// The single active dialog. The browser renders whichever is `Some`.
40#[derive(Clone)]
41pub enum Dialog {
42 /// Text input (new folder, rename).
43 Prompt {
44 title: String,
45 label: String,
46 initial: String,
47 submit: String,
48 on_submit: Callback<String>,
49 },
50 /// Simple confirmation (delete).
51 Confirm {
52 title: String,
53 message: String,
54 submit: String,
55 danger: bool,
56 on_submit: Callback<()>,
57 },
58 /// Overwrite warning listing the conflicting files.
59 Conflict {
60 title: String,
61 files: Vec<String>,
62 on_submit: Callback<()>,
63 },
64 /// Move/copy destination picker.
65 Picker {
66 op: Op,
67 entry: Entry,
68 roots: Vec<RootInfo>,
69 source_root: i64,
70 source_dir: String,
71 on_pick: Callback<(i64, String)>,
72 },
73 /// Folder download — format choice (zip | tar | tar.gz | tar.zst).
74 DownloadFormat {
75 name: String,
76 root_id: i64,
77 path: String,
78 },
79 /// Read-only item info.
80 Info {
81 entry: Entry,
82 root_name: String,
83 rel_path: String,
84 },
85 /// Create a share link for an item.
86 Share {
87 name: String,
88 root_id: i64,
89 path: String,
90 /// The server's global "allow writable shares" setting.
91 allow_writable: bool,
92 /// Whether the item's root is writable — a writable share is
93 /// impossible from a read-only root, so the checkbox hides.
94 root_writable: bool,
95 },
96}
97
98/// Split a "/"-separated directory path into its non-empty segments.
99fn dir_segs(dir: &str) -> Vec<String> {
100 dir.split('/')
101 .filter(|s| !s.is_empty())
102 .map(|s| s.to_string())
103 .collect()
104}
105
106#[component]
107pub fn DialogView(
108 dialog: ReadSignal<Option<Dialog>>,
109 set_dialog: WriteSignal<Option<Dialog>>,
110) -> impl IntoView {
111 // Escape closes the active dialog.
112 {
113 let d = dialog;
114 let set_d = set_dialog;
115 crate::util::owned_window_listener(
116 leptos::ev::keydown,
117 move |ev: web_sys::KeyboardEvent| {
118 if ev.key() == "Escape" && d.get().is_some() {
119 set_d.set(None);
120 }
121 },
122 );
123 }
124 view! {
125 {move || {
126 let Some(d) = dialog.get() else {
127 return {
128 view! {}.into_any()
129 };
130 };
131 let close = Callback::new(move |_| set_dialog.set(None));
132 match &d {
133 Dialog::Prompt {
134 title,
135 label,
136 initial,
137 submit,
138 on_submit,
139 } => {
140 let (title, label, initial, submit, on_submit, close) = (
141 title.clone(),
142 label.clone(),
143 initial.clone(),
144 submit.clone(),
145 *on_submit,
146 close,
147 );
148 view! {
149 <PromptDialog
150 title=title
151 label=label
152 initial=initial
153 submit=submit
154 on_submit=on_submit
155 on_close=close
156 />
157 }
158 .into_view()
159 .into_any()
160 }
161 Dialog::Confirm {
162 title,
163 message,
164 submit,
165 danger,
166 on_submit,
167 } => {
168 let (title, message, submit, danger, on_submit, close) = (
169 title.clone(),
170 message.clone(),
171 submit.clone(),
172 *danger,
173 *on_submit,
174 close,
175 );
176 view! {
177 <ConfirmDialog
178 title=title
179 message=message
180 submit=submit
181 danger=danger
182 on_submit=on_submit
183 on_close=close
184 />
185 }
186 .into_view()
187 .into_any()
188 }
189 Dialog::Conflict {
190 title,
191 files,
192 on_submit,
193 } => {
194 let (title, files, on_submit, close) = (
195 title.clone(),
196 files.clone(),
197 *on_submit,
198 close,
199 );
200 view! {
201 <ConflictDialog
202 title=title
203 files=files
204 on_submit=on_submit
205 on_close=close
206 />
207 }
208 .into_view()
209 .into_any()
210 }
211 Dialog::Picker {
212 op,
213 entry,
214 roots,
215 source_root,
216 source_dir,
217 on_pick,
218 } => {
219 let (op, entry, roots, source_root, source_dir, on_pick, close) = (
220 *op,
221 entry.clone(),
222 roots.clone(),
223 *source_root,
224 source_dir.clone(),
225 *on_pick,
226 close,
227 );
228 view! {
229 <PickerDialog
230 op=op
231 entry=entry
232 roots=roots
233 source_root=source_root
234 source_dir=source_dir
235 on_pick=on_pick
236 on_close=close
237 />
238 }
239 .into_view()
240 .into_any()
241 }
242 Dialog::DownloadFormat {
243 name,
244 root_id,
245 path,
246 } => {
247 let (name, root_id, path) = (
248 name.clone(),
249 *root_id,
250 path.clone(),
251 );
252 view! {
253 <DownloadFormatDialog
254 name=name
255 root_id=root_id
256 path=path
257 on_close=close
258 />
259 }
260 .into_view()
261 .into_any()
262 }
263 Dialog::Info {
264 entry,
265 root_name,
266 rel_path,
267 } => {
268 let (entry, root_name, rel_path) = (
269 entry.clone(),
270 root_name.clone(),
271 rel_path.clone(),
272 );
273 view! {
274 <InfoDialog
275 entry=entry
276 root_name=root_name
277 rel_path=rel_path
278 on_close=close
279 />
280 }
281 .into_view()
282 .into_any()
283 }
284 Dialog::Share {
285 name,
286 root_id,
287 path,
288 allow_writable,
289 root_writable,
290 } => {
291 let (name, root_id, path, allow_writable, root_writable) = (
292 name.clone(),
293 *root_id,
294 path.clone(),
295 *allow_writable,
296 *root_writable,
297 );
298 view! {
299 <crate::views::shares::ShareDialog
300 name=name
301 root_id=root_id
302 path=path
303 allow_writable=allow_writable
304 root_writable=root_writable
305 close=close
306 />
307 }
308 .into_view()
309 .into_any()
310 }
311 }
312 }}
313 }
314}
315
316// ---------------------------------------------------------------------------
317// Prompt (text input)
318// ---------------------------------------------------------------------------
319
320#[component]
321fn PromptDialog(
322 title: String,
323 label: String,
324 initial: String,
325 submit: String,
326 on_submit: Callback<String>,
327 on_close: Callback<()>,
328) -> impl IntoView {
329 let input_id = "fbng-prompt-input";
330 let id = input_id.to_string();
331 let id2 = id.clone();
332 let submit_cb = on_submit;
333 view! {
334 <div class="modal-overlay" on:click=move |_| on_close.run(())>
335 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
336 <h2 class="modal-title">{title}</h2>
337 <label class="modal-label" for=input_id>{label}</label>
338 <input
339 id=input_id
340 class="input"
341 value=initial
342 autofocus=true
343 on:keydown=move |e: web_sys::KeyboardEvent| {
344 if e.key() == "Enter" {
345 e.prevent_default();
346 submit_cb.run(api::input_value(&id));
347 }
348 }
349 />
350 <div class="modal-actions">
351 <button class="btn" on:click=move |_| on_close.run(())>
352 {i18n::tr(i18n::k::CANCEL)}
353 </button>
354 <button
355 class="btn btn-primary"
356 on:click=move |_| submit_cb.run(api::input_value(&id2))
357 >
358 {submit}
359 </button>
360 </div>
361 </div>
362 </div>
363 }
364}
365
366// ---------------------------------------------------------------------------
367// Confirm
368// ---------------------------------------------------------------------------
369
370#[component]
371fn ConfirmDialog(
372 title: String,
373 message: String,
374 submit: String,
375 danger: bool,
376 on_submit: Callback<()>,
377 on_close: Callback<()>,
378) -> impl IntoView {
379 view! {
380 <div class="modal-overlay" on:click=move |_| on_close.run(())>
381 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
382 <h2 class="modal-title">{title}</h2>
383 <p class="modal-message">{message}</p>
384 <div class="modal-actions">
385 <button class="btn" on:click=move |_| on_close.run(())>
386 {i18n::tr(i18n::k::CANCEL)}
387 </button>
388 <button
389 class=move || {
390 if danger {
391 "btn btn-danger".to_string()
392 } else {
393 "btn btn-primary".to_string()
394 }
395 }
396 on:click=move |_| {
397 on_submit.run(());
398 on_close.run(());
399 }
400 >
401 {submit}
402 </button>
403 </div>
404 </div>
405 </div>
406 }
407}
408
409// ---------------------------------------------------------------------------
410// Conflict (overwrite warning)
411// ---------------------------------------------------------------------------
412
413#[component]
414fn ConflictDialog(
415 title: String,
416 files: Vec<String>,
417 on_submit: Callback<()>,
418 on_close: Callback<()>,
419) -> impl IntoView {
420 let shown: Vec<String> = files.iter().take(20).cloned().collect();
421 let extra = files.len().saturating_sub(shown.len());
422 view! {
423 <div class="modal-overlay" on:click=move |_| on_close.run(())>
424 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
425 <h2 class="modal-title">{title}</h2>
426 <p class="modal-message">
427 {i18n::tr(i18n::k::OVERWRITE_QUESTION)}
428 </p>
429 <ul class="conflict-list">
430 {shown.iter().map(|f| {
431 let f = f.clone();
432 view! { <li>{f}</li> }
433 }).collect::<Vec<_>>()}
434 {move || {
435 if extra > 0 {
436 view! { <li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li> }
437 .into_view()
438 .into_any()
439 } else {
440 view! {}.into_any()
441 }
442 }}
443 </ul>
444 <div class="modal-actions">
445 <button class="btn" on:click=move |_| on_close.run(())>
446 {i18n::tr(i18n::k::CANCEL)}
447 </button>
448 <button
449 class="btn btn-danger"
450 on:click=move |_| {
451 on_submit.run(());
452 on_close.run(());
453 }
454 >
455 {i18n::tr(i18n::k::OVERWRITE)}
456 </button>
457 </div>
458 </div>
459 </div>
460 }
461}
462
463// ---------------------------------------------------------------------------
464// Picker (move / copy destination)
465// ---------------------------------------------------------------------------
466
467#[component]
468fn PickerDialog(
469 op: Op,
470 entry: Entry,
471 roots: Vec<RootInfo>,
472 source_root: i64,
473 source_dir: String,
474 on_pick: Callback<(i64, String)>,
475 on_close: Callback<()>,
476) -> impl IntoView {
477 let (sel_root, set_sel_root) = signal(source_root);
478 let (sel_dir, set_sel_dir) = signal(source_dir.clone());
479 let (entries, set_entries) = signal(Vec::<Entry>::new());
480
481 // Load the subfolders of the selected directory whenever it changes.
482 {
483 let root_sig = sel_root;
484 let dir_sig = sel_dir;
485 let set_ent = set_entries;
486 Effect::new(move |_| {
487 let rid = root_sig.get();
488 let dir = dir_sig.get();
489 set_ent.set(Vec::new());
490 spawn_local(async move {
491 match api::list_files(rid, &dir).await {
492 Ok(r) => set_ent.set(r.entries.into_iter().filter(|e| e.is_dir).collect()),
493 Err(_) => set_ent.set(Vec::new()),
494 }
495 });
496 });
497 }
498
499 let sel_root2 = sel_root;
500 let sel_dir2 = sel_dir;
501 let set_dir = set_sel_dir;
502 let roots_by_id = roots.clone();
503 view! {
504 <div class="modal-overlay" on:click=move |_| on_close.run(())>
505 <div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
506 <h2 class="modal-title">
507 {move || i18n::t_fmt(op.title_key(), &entry.name)}
508 </h2>
509
510 <div class="picker-roots">
511 {roots.iter().map(|r| {
512 let rid = r.id;
513 let ro = !r.mode.is_writable();
514 let name = if ro {
515 i18n::t_fmt(i18n::k::READ_ONLY_ROOT, &r.name)
516 } else {
517 r.name.clone()
518 };
519 view! {
520 <button
521 class=move || {
522 if rid == sel_root2.get() {
523 "btn root-opt active".to_string()
524 } else {
525 "btn root-opt".to_string()
526 }
527 }
528 on:click=move |_| {
529 set_sel_root.set(rid);
530 set_sel_dir.set(String::new());
531 }
532 >
533 {name}
534 </button>
535 }
536 }).collect::<Vec<_>>()}
537 </div>
538
539 <p class="picker-current muted">
540 {move || {
541 let rid = sel_root2.get();
542 let rname = roots_by_id
543 .iter()
544 .find(|r| r.id == rid)
545 .map(|r| r.name.clone())
546 .unwrap_or_default();
547 let dir = sel_dir2.get();
548 if dir.is_empty() {
549 format!("{rname}/")
550 } else {
551 format!("{rname}/{dir}")
552 }
553 }}
554 </p>
555
556 // Breadcrumbs within the current directory.
557 <div class="crumbs picker-crumbs">
558 <span
559 class="crumb"
560 on:click=move |_| set_dir.set(String::new())
561 >
562 {i18n::tr(i18n::k::TOP)}
563 </span>
564 {move || {
565 let segs = dir_segs(&sel_dir2.get());
566 segs
567 .iter()
568 .enumerate()
569 .map(|(i, seg)| {
570 let seg = seg.clone();
571 let prefix: String = segs[..=i].join("/");
572 let is_last = i == segs.len() - 1;
573 view! {
574 <span class="crumb-sep">"/"</span>
575 <span
576 class=move || {
577 if is_last {
578 "crumb current".to_string()
579 } else {
580 "crumb".to_string()
581 }
582 }
583 on:click=move |_| set_dir.set(prefix.clone())
584 >
585 {seg}
586 </span>
587 }
588 })
589 .collect::<Vec<_>>()
590 }}
591 </div>
592
593 <div class="picker-entries">
594 {move || {
595 let es = entries.get();
596 if es.is_empty() {
597 view! {
598 <p class="muted center-note">
599 {i18n::tr(i18n::k::NO_SUBFOLDERS)}
600 </p>
601 }
602 .into_view()
603 .into_any()
604 } else {
605 es.iter()
606 .map(|e| {
607 let name = e.name.clone();
608 let name2 = name.clone();
609 view! {
610 <div
611 class="picker-row"
612 on:click=move |_| {
613 let cur = sel_dir2.get();
614 let newdir = if cur.is_empty() {
615 name2.clone()
616 } else {
617 format!("{cur}/{name2}")
618 };
619 set_dir.set(newdir);
620 }
621 >
622 <Icon name=IconName::Folder class="row-icon".to_string()/>
623 <span class="row-name">{name}</span>
624 </div>
625 }
626 })
627 .collect::<Vec<_>>()
628 .into_view()
629 .into_any()
630 }
631 }}
632 </div>
633
634 <div class="modal-actions">
635 <button class="btn" on:click=move |_| on_close.run(())>
636 {i18n::tr(i18n::k::CANCEL)}
637 </button>
638 <button
639 class="btn btn-primary"
640 disabled=move || {
641 // Moving/copying into the exact same spot is a no-op.
642 sel_root2.get() == source_root && sel_dir2.get() == source_dir
643 }
644 on:click=move |_| {
645 let rid = sel_root2.get();
646 let dir = sel_dir2.get();
647 on_pick.run((rid, dir));
648 on_close.run(());
649 }
650 >
651 {i18n::tr(op.here_key())}
652 </button>
653 </div>
654 </div>
655 </div>
656 }
657}
658
659// ---------------------------------------------------------------------------
660// Download format (folder archive choice)
661// ---------------------------------------------------------------------------
662
663#[component]
664fn DownloadFormatDialog(
665 name: String,
666 root_id: i64,
667 path: String,
668 on_close: Callback<()>,
669) -> impl IntoView {
670 let toast = use_context::<ToastMsg>().expect("toast context");
671 let name_title = name.clone();
672 let formats: [(&str, &str, &str); 4] = [
673 ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
674 ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
675 ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
676 ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
677 ];
678 view! {
679 <div class="modal-overlay" on:click=move |_| on_close.run(())>
680 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
681 <h2 class="modal-title">
682 {move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
683 </h2>
684 <p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
685 <div class="dl-formats">
686 {formats.iter().map(|(id, label, ext)| {
687 let (id, label, ext) = (*id, *label, *ext);
688 let path = path.clone();
689 let name = name.clone();
690 view! {
691 <button
692 class="btn btn-primary dl-fmt"
693 on:click=move |_| {
694 let url = api::download_url(root_id, &path, Some(id));
695 let fname = format!("{name}{ext}");
696 api::trigger_download(&url, &fname);
697 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
698 on_close.run(());
699 }
700 >
701 {label}
702 </button>
703 }
704 }).collect::<Vec<_>>()}
705 </div>
706 <div class="modal-actions">
707 <button class="btn" on:click=move |_| on_close.run(())>
708 {i18n::tr(i18n::k::CANCEL)}
709 </button>
710 </div>
711 </div>
712 </div>
713 }
714}
715
716// ---------------------------------------------------------------------------
717// Item info
718// ---------------------------------------------------------------------------
719
720#[component]
721fn InfoDialog(
722 entry: Entry,
723 root_name: String,
724 rel_path: String,
725 on_close: Callback<()>,
726) -> impl IntoView {
727 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
728 let is_dir = entry.is_dir;
729 let size = if is_dir {
730 "—".to_string()
731 } else {
732 crate::util::format_size(entry.size)
733 };
734 let modified = crate::util::format_date(&entry.mtime);
735 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
736 let location = if parent.is_empty() {
737 format!("{root_name}/")
738 } else {
739 format!("{root_name}/{parent}")
740 };
741 view! {
742 <div class="modal-overlay" on:click=move |_| on_close.run(())>
743 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
744 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
745 <dl class="info-list">
746 <dt>{i18n::tr(i18n::k::NAME)}</dt>
747 <dd>{entry.name.clone()}</dd>
748 <dt>{i18n::tr(i18n::k::TYPE)}</dt>
749 <dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
750 <dt>{i18n::tr(i18n::k::SIZE)}</dt>
751 <dd>{size}</dd>
752 <dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
753 <dd>{modified}</dd>
754 <dt>{i18n::tr(i18n::k::LOCATION)}</dt>
755 <dd>{location}</dd>
756 </dl>
757 <div class="modal-actions">
758 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
759 {i18n::tr(i18n::k::CLOSE)}
760 </button>
761 </div>
762 </div>
763 </div>
764 }
765}
766