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