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::{ToastMsg, show};
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 crate::util::owned_window_listener(
101 leptos::ev::keydown,
102 move |ev: web_sys::KeyboardEvent| {
103 if ev.key() == "Escape" && d.get().is_some() {
104 set_d.set(None);
105 }
106 },
107 );
108 }
109 view! {
110 {move || {
111 let Some(d) = dialog.get() else {
112 return {
113 view! {}.into_any()
114 };
115 };
116 let close = Callback::new(move |_| set_dialog.set(None));
117 match &d {
118 Dialog::Prompt {
119 title,
120 label,
121 initial,
122 submit,
123 on_submit,
124 } => {
125 let (title, label, initial, submit, on_submit, close) = (
126 title.clone(),
127 label.clone(),
128 initial.clone(),
129 submit.clone(),
130 *on_submit,
131 close,
132 );
133 view! {
134 <PromptDialog
135 title=title
136 label=label
137 initial=initial
138 submit=submit
139 on_submit=on_submit
140 on_close=close
141 />
142 }
143 .into_view()
144 .into_any()
145 }
146 Dialog::Confirm {
147 title,
148 message,
149 submit,
150 danger,
151 on_submit,
152 } => {
153 let (title, message, submit, danger, on_submit, close) = (
154 title.clone(),
155 message.clone(),
156 submit.clone(),
157 *danger,
158 *on_submit,
159 close,
160 );
161 view! {
162 <ConfirmDialog
163 title=title
164 message=message
165 submit=submit
166 danger=danger
167 on_submit=on_submit
168 on_close=close
169 />
170 }
171 .into_view()
172 .into_any()
173 }
174 Dialog::Conflict {
175 title,
176 files,
177 on_submit,
178 } => {
179 let (title, files, on_submit, close) = (
180 title.clone(),
181 files.clone(),
182 *on_submit,
183 close,
184 );
185 view! {
186 <ConflictDialog
187 title=title
188 files=files
189 on_submit=on_submit
190 on_close=close
191 />
192 }
193 .into_view()
194 .into_any()
195 }
196 Dialog::Picker {
197 op,
198 entry,
199 roots,
200 source_root,
201 source_dir,
202 on_pick,
203 } => {
204 let (op, entry, roots, source_root, source_dir, on_pick, close) = (
205 *op,
206 entry.clone(),
207 roots.clone(),
208 *source_root,
209 source_dir.clone(),
210 *on_pick,
211 close,
212 );
213 view! {
214 <PickerDialog
215 op=op
216 entry=entry
217 roots=roots
218 source_root=source_root
219 source_dir=source_dir
220 on_pick=on_pick
221 on_close=close
222 />
223 }
224 .into_view()
225 .into_any()
226 }
227 Dialog::DownloadFormat {
228 name,
229 root_id,
230 path,
231 } => {
232 let (name, root_id, path) = (
233 name.clone(),
234 *root_id,
235 path.clone(),
236 );
237 view! {
238 <DownloadFormatDialog
239 name=name
240 root_id=root_id
241 path=path
242 on_close=close
243 />
244 }
245 .into_view()
246 .into_any()
247 }
248 Dialog::Info {
249 entry,
250 root_name,
251 rel_path,
252 } => {
253 let (entry, root_name, rel_path) = (
254 entry.clone(),
255 root_name.clone(),
256 rel_path.clone(),
257 );
258 view! {
259 <InfoDialog
260 entry=entry
261 root_name=root_name
262 rel_path=rel_path
263 on_close=close
264 />
265 }
266 .into_view()
267 .into_any()
268 }
269 Dialog::Share {
270 name,
271 root_id,
272 path,
273 allow_writable,
274 } => {
275 let (name, root_id, path, allow_writable) = (
276 name.clone(),
277 *root_id,
278 path.clone(),
279 *allow_writable,
280 );
281 view! {
282 <crate::views::shares::ShareDialog
283 name=name
284 root_id=root_id
285 path=path
286 allow_writable=allow_writable
287 close=close
288 />
289 }
290 .into_view()
291 .into_any()
292 }
293 }
294 }}
295 }
296}
297
298// ---------------------------------------------------------------------------
299// Prompt (text input)
300// ---------------------------------------------------------------------------
301
302#[component]
303fn PromptDialog(
304 title: String,
305 label: String,
306 initial: String,
307 submit: String,
308 on_submit: Callback<String>,
309 on_close: Callback<()>,
310) -> impl IntoView {
311 let input_id = "fbng-prompt-input";
312 let id = input_id.to_string();
313 let id2 = id.clone();
314 let submit_cb = on_submit;
315 view! {
316 <div class="modal-overlay" on:click=move |_| on_close.run(())>
317 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
318 <h2 class="modal-title">{title}</h2>
319 <label class="modal-label" for=input_id>{label}</label>
320 <input
321 id=input_id
322 class="input"
323 value=initial
324 autofocus=true
325 on:keydown=move |e: web_sys::KeyboardEvent| {
326 if e.key() == "Enter" {
327 e.prevent_default();
328 submit_cb.run(api::input_value(&id));
329 }
330 }
331 />
332 <div class="modal-actions">
333 <button class="btn" on:click=move |_| on_close.run(())>
334 "Cancel"
335 </button>
336 <button
337 class="btn btn-primary"
338 on:click=move |_| submit_cb.run(api::input_value(&id2))
339 >
340 {submit}
341 </button>
342 </div>
343 </div>
344 </div>
345 }
346}
347
348// ---------------------------------------------------------------------------
349// Confirm
350// ---------------------------------------------------------------------------
351
352#[component]
353fn ConfirmDialog(
354 title: String,
355 message: String,
356 submit: String,
357 danger: bool,
358 on_submit: Callback<()>,
359 on_close: Callback<()>,
360) -> impl IntoView {
361 view! {
362 <div class="modal-overlay" on:click=move |_| on_close.run(())>
363 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
364 <h2 class="modal-title">{title}</h2>
365 <p class="modal-message">{message}</p>
366 <div class="modal-actions">
367 <button class="btn" on:click=move |_| on_close.run(())>
368 "Cancel"
369 </button>
370 <button
371 class=move || {
372 if danger {
373 "btn btn-danger".to_string()
374 } else {
375 "btn btn-primary".to_string()
376 }
377 }
378 on:click=move |_| {
379 on_submit.run(());
380 on_close.run(());
381 }
382 >
383 {submit}
384 </button>
385 </div>
386 </div>
387 </div>
388 }
389}
390
391// ---------------------------------------------------------------------------
392// Conflict (overwrite warning)
393// ---------------------------------------------------------------------------
394
395#[component]
396fn ConflictDialog(
397 title: String,
398 files: Vec<String>,
399 on_submit: Callback<()>,
400 on_close: Callback<()>,
401) -> impl IntoView {
402 let shown: Vec<String> = files.iter().take(20).cloned().collect();
403 let extra = files.len().saturating_sub(shown.len());
404 view! {
405 <div class="modal-overlay" on:click=move |_| on_close.run(())>
406 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
407 <h2 class="modal-title">{title}</h2>
408 <p class="modal-message">
409 "The following files already exist. Overwrite them?"
410 </p>
411 <ul class="conflict-list">
412 {shown.iter().map(|f| {
413 let f = f.clone();
414 view! { <li>{f}</li> }
415 }).collect::<Vec<_>>()}
416 {move || {
417 if extra > 0 {
418 view! { <li class="muted">format!("…and {extra} more") </li> }
419 .into_view()
420 .into_any()
421 } else {
422 view! {}.into_any()
423 }
424 }}
425 </ul>
426 <div class="modal-actions">
427 <button class="btn" on:click=move |_| on_close.run(())>
428 "Cancel"
429 </button>
430 <button
431 class="btn btn-danger"
432 on:click=move |_| {
433 on_submit.run(());
434 on_close.run(());
435 }
436 >
437 "Overwrite"
438 </button>
439 </div>
440 </div>
441 </div>
442 }
443}
444
445// ---------------------------------------------------------------------------
446// Picker (move / copy destination)
447// ---------------------------------------------------------------------------
448
449#[component]
450fn PickerDialog(
451 op: Op,
452 entry: Entry,
453 roots: Vec<RootInfo>,
454 source_root: i64,
455 source_dir: String,
456 on_pick: Callback<(i64, String)>,
457 on_close: Callback<()>,
458) -> impl IntoView {
459 let (sel_root, set_sel_root) = signal(source_root);
460 let (sel_dir, set_sel_dir) = signal(source_dir.clone());
461 let (entries, set_entries) = signal(Vec::<Entry>::new());
462
463 // Load the subfolders of the selected directory whenever it changes.
464 {
465 let root_sig = sel_root;
466 let dir_sig = sel_dir;
467 let set_ent = set_entries;
468 Effect::new(move |_| {
469 let rid = root_sig.get();
470 let dir = dir_sig.get();
471 set_ent.set(Vec::new());
472 spawn_local(async move {
473 match api::list_files(rid, &dir).await {
474 Ok(r) => set_ent.set(r.entries.into_iter().filter(|e| e.is_dir).collect()),
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 path = path.clone();
668 let name = name.clone();
669 view! {
670 <button
671 class="btn btn-primary dl-fmt"
672 on:click=move |_| {
673 let url = api::download_url(root_id, &path, Some(id));
674 let fname = format!("{name}{ext}");
675 api::trigger_download(&url, &fname);
676 show(toast, "Download started");
677 on_close.run(());
678 }
679 >
680 {label}
681 </button>
682 }
683 }).collect::<Vec<_>>()}
684 </div>
685 <div class="modal-actions">
686 <button class="btn" on:click=move |_| on_close.run(())>
687 "Cancel"
688 </button>
689 </div>
690 </div>
691 </div>
692 }
693}
694
695// ---------------------------------------------------------------------------
696// Item info
697// ---------------------------------------------------------------------------
698
699#[component]
700fn InfoDialog(
701 entry: Entry,
702 root_name: String,
703 rel_path: String,
704 on_close: Callback<()>,
705) -> impl IntoView {
706 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
707 let is_dir = entry.is_dir;
708 let size = if is_dir {
709 "—".to_string()
710 } else {
711 crate::util::format_size(entry.size)
712 };
713 let modified = crate::util::format_date(&entry.mtime);
714 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
715 let location = if parent.is_empty() {
716 format!("{root_name}/")
717 } else {
718 format!("{root_name}/{parent}")
719 };
720 view! {
721 <div class="modal-overlay" on:click=move |_| on_close.run(())>
722 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
723 <h2 class="modal-title">"File info"</h2>
724 <dl class="info-list">
725 <dt>"Name"</dt>
726 <dd>{entry.name.clone()}</dd>
727 <dt>"Type"</dt>
728 <dd>{if is_dir { "Folder".to_string() } else { "File".to_string() }}</dd>
729 <dt>"Size"</dt>
730 <dd>{size}</dd>
731 <dt>"Modified"</dt>
732 <dd>{modified}</dd>
733 <dt>"Location"</dt>
734 <dd>{location}</dd>
735 </dl>
736 <div class="modal-actions">
737 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
738 "Close"
739 </button>
740 </div>
741 </div>
742 </div>
743 }
744}
745