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