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