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