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 std::sync::Arc;
5
6use leptos::prelude::*;
7use wasm_bindgen_futures::spawn_local;
8
9use crate::api::{self, Entry, RootInfo};
10use crate::components::icon::Icon;
11use crate::components::modal::Modal;
12use crate::components::toast::{ToastMsg, show, show_error};
13use crate::i18n;
14use crate::icons::IconName;
15
16/// Which mutating operation the destination picker is for.
17#[derive(Clone, Copy, PartialEq)]
18pub enum Op {
19 Move,
20 Copy,
21}
22
23impl Op {
24 /// The destination picker's title for `n` items: "Move “{}” to…" for
25 /// one (the name goes in), "Move {} items to…" for more (the count).
26 pub fn title_key(self, n: usize) -> &'static str {
27 use crate::i18n::k;
28 match (self, n) {
29 (Op::Move, 1) => k::MOVE_TO,
30 (Op::Copy, 1) => k::COPY_TO,
31 (Op::Move, _) => k::MOVE_N_TO,
32 (Op::Copy, _) => k::COPY_N_TO,
33 }
34 }
35
36 /// The translated "… here" confirm button label.
37 pub fn here_key(self) -> &'static str {
38 match self {
39 Op::Move => crate::i18n::k::MOVE_HERE,
40 Op::Copy => crate::i18n::k::COPY_HERE,
41 }
42 }
43}
44
45/// The single active dialog. The browser renders whichever is `Some`.
46#[derive(Clone)]
47pub enum Dialog {
48 /// Text input (new folder, new file, rename). Submit runs an async
49 /// operation; failures are shown inside the dialog, which stays open,
50 /// and on success it closes.
51 PromptOp {
52 title: String,
53 label: String,
54 initial: String,
55 submit: String,
56 /// Runs the operation for the submitted (trimmed, non-empty) name.
57 /// Returns the localized error message on failure.
58 run: Callback<String, PromptOpFuture>,
59 /// Runs after a successful operation (toast + refresh); the dialog
60 /// closes regardless.
61 on_ok: Callback<()>,
62 },
63 /// Simple confirmation (delete).
64 Confirm {
65 title: String,
66 message: String,
67 submit: String,
68 danger: bool,
69 on_submit: Callback<()>,
70 },
71 /// Overwrite warning listing the conflicting files.
72 Conflict {
73 title: String,
74 files: Vec<String>,
75 on_submit: Callback<()>,
76 },
77 /// Upload pre-check found existing files. Overwrite or skip them, and
78 /// optionally make that the rule for files that appear mid-transfer.
79 UploadConflict {
80 files: Vec<String>,
81 /// `(overwrite, apply_to_all)`.
82 /// `None` when the user cancelled (button, Escape, backdrop).
83 on_decide: Callback<Option<(bool, bool)>>,
84 },
85 /// Folder picker: the move/copy destination, or where a search starts.
86 Picker {
87 title: String,
88 /// The confirm button label ("Move here", "Search here").
89 confirm: String,
90 roots: Vec<RootInfo>,
91 /// The folder the picker opens in.
92 root: i64,
93 dir: String,
94 /// The picked folder is written to (move/copy destination): only a
95 /// writable root can be confirmed, and not the folder the picker
96 /// opened in (a move or copy onto itself is a no-op).
97 for_write: bool,
98 on_pick: Callback<(i64, String)>,
99 },
100 /// Folder download — format choice (zip | tar | tar.gz | tar.zst).
101 DownloadFormat {
102 name: String,
103 root_id: i64,
104 path: String,
105 },
106 /// Read-only item info.
107 Info {
108 entry: Entry,
109 root_name: String,
110 rel_path: String,
111 },
112 /// Create a share link for an item.
113 Share {
114 name: String,
115 root_id: i64,
116 path: String,
117 /// The server's global "allow writable shares" setting.
118 allow_writable: bool,
119 /// Whether the item's root is writable — a writable share is
120 /// impossible from a read-only root, so the checkbox hides.
121 root_writable: bool,
122 },
123}
124
125/// The future returned by [`Dialog::PromptOp`]'s `run` callback.
126pub type PromptOpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
127
128/// Split a "/"-separated directory path into its non-empty segments.
129fn dir_segs(dir: &str) -> Vec<String> {
130 dir.split('/')
131 .filter(|s| !s.is_empty())
132 .map(|s| s.to_string())
133 .collect()
134}
135
136/// Wait until the shell's one dialog slot is free. A background task
137/// (upload or move conflict) must not replace a dialog that is showing,
138/// whether another task or the user opened it.
139///
140/// ponytail: 200 ms poll, wake channel if latency matters.
141pub async fn claim_async_dialog(dialog: ReadSignal<Option<Dialog>>) {
142 while dialog.get_untracked().is_some() {
143 gloo_timers::future::sleep(std::time::Duration::from_millis(200)).await;
144 }
145}
146
147#[component]
148pub fn DialogView(
149 dialog: ReadSignal<Option<Dialog>>,
150 set_dialog: WriteSignal<Option<Dialog>>,
151) -> impl IntoView {
152 // Escape is the <dialog>'s own; see `Modal`.
153 view! {
154 {move || {
155 let Some(d) = dialog.get() else {
156 return {
157 view! {}.into_any()
158 };
159 };
160 let close = Callback::new(move |_| set_dialog.set(None));
161 match &d {
162 Dialog::PromptOp {
163 title,
164 label,
165 initial,
166 submit,
167 run,
168 on_ok,
169 } => {
170 let (title, label, initial, submit, run, on_ok, close) = (
171 title.clone(),
172 label.clone(),
173 initial.clone(),
174 submit.clone(),
175 *run,
176 *on_ok,
177 close,
178 );
179 view! {
180 <PromptOpDialog
181 title=title
182 label=label
183 initial=initial
184 submit=submit
185 run=run
186 on_ok=on_ok
187 on_close=close
188 />
189 }
190 .into_view()
191 .into_any()
192 }
193 Dialog::Confirm {
194 title,
195 message,
196 submit,
197 danger,
198 on_submit,
199 } => {
200 let (title, message, submit, danger, on_submit, close) = (
201 title.clone(),
202 message.clone(),
203 submit.clone(),
204 *danger,
205 *on_submit,
206 close,
207 );
208 view! {
209 <ConfirmDialog
210 title=title
211 message=message
212 submit=submit
213 danger=danger
214 on_submit=on_submit
215 on_close=close
216 />
217 }
218 .into_view()
219 .into_any()
220 }
221 Dialog::UploadConflict { files, on_decide } => {
222 let (files, on_decide) = (files.clone(), *on_decide);
223 view! {
224 <UploadConflictDialog files=files on_decide=on_decide on_close=close/>
225 }
226 .into_view()
227 .into_any()
228 }
229 Dialog::Conflict {
230 title,
231 files,
232 on_submit,
233 } => {
234 let (title, files, on_submit, close) = (
235 title.clone(),
236 files.clone(),
237 *on_submit,
238 close,
239 );
240 view! {
241 <ConflictDialog
242 title=title
243 files=files
244 on_submit=on_submit
245 on_close=close
246 />
247 }
248 .into_view()
249 .into_any()
250 }
251 Dialog::Picker {
252 title,
253 confirm,
254 roots,
255 root,
256 dir,
257 for_write,
258 on_pick,
259 } => {
260 view! {
261 <PickerDialog
262 title=title.clone()
263 confirm=confirm.clone()
264 roots=roots.clone()
265 root=*root
266 dir=dir.clone()
267 for_write=*for_write
268 on_pick=*on_pick
269 on_close=close
270 />
271 }
272 .into_view()
273 .into_any()
274 }
275 Dialog::DownloadFormat {
276 name,
277 root_id,
278 path,
279 } => {
280 let (name, root_id, path) = (
281 name.clone(),
282 *root_id,
283 path.clone(),
284 );
285 view! {
286 <DownloadFormatDialog
287 name=name
288 root_id=root_id
289 path=path
290 on_close=close
291 />
292 }
293 .into_view()
294 .into_any()
295 }
296 Dialog::Info {
297 entry,
298 root_name,
299 rel_path,
300 } => {
301 let (entry, root_name, rel_path) = (
302 entry.clone(),
303 root_name.clone(),
304 rel_path.clone(),
305 );
306 view! {
307 <InfoDialog
308 entry=entry
309 root_name=root_name
310 rel_path=rel_path
311 on_close=close
312 />
313 }
314 .into_view()
315 .into_any()
316 }
317 Dialog::Share {
318 name,
319 root_id,
320 path,
321 allow_writable,
322 root_writable,
323 } => {
324 let (name, root_id, path, allow_writable, root_writable) = (
325 name.clone(),
326 *root_id,
327 path.clone(),
328 *allow_writable,
329 *root_writable,
330 );
331 view! {
332 <crate::views::shares::ShareDialog
333 name=name
334 root_id=root_id
335 path=path
336 allow_writable=allow_writable
337 root_writable=root_writable
338 close=close
339 />
340 }
341 .into_view()
342 .into_any()
343 }
344 }
345 }}
346 }
347}
348
349// ---------------------------------------------------------------------------
350// Prompt (text input running an async operation)
351// ---------------------------------------------------------------------------
352
353#[component]
354fn PromptOpDialog(
355 title: String,
356 label: String,
357 initial: String,
358 submit: String,
359 run: Callback<String, PromptOpFuture>,
360 on_ok: Callback<()>,
361 on_close: Callback<()>,
362) -> impl IntoView {
363 let input_id = "fbng-prompt-op-input";
364 let (error, set_error) = signal(Option::<String>::None);
365 let (busy, set_busy) = signal(false);
366 // The closure captures only Copy values, so the Enter-key and button
367 // handlers can share it directly.
368 let submit_fn = move || {
369 let name = api::input_value(input_id).trim().to_string();
370 if name.is_empty() || busy.get() {
371 return;
372 }
373 set_busy.set(true);
374 set_error.set(None);
375 let fut = run.run(name);
376 spawn_local(async move {
377 match fut.await {
378 Ok(()) => {
379 on_ok.run(());
380 on_close.run(());
381 }
382 Err(msg) => {
383 set_error.set(Some(msg));
384 set_busy.set(false);
385 }
386 }
387 });
388 };
389 view! {
390 <Modal class="card" on_close=on_close>
391 <h2 class="modal-title">{title}</h2>
392 <label class="modal-label" for=input_id>{label}</label>
393 <input
394 id=input_id
395 class="input"
396 value=initial
397 autofocus=true
398 on:input=move |_| set_error.set(None)
399 on:keydown=move |e: web_sys::KeyboardEvent| {
400 if e.key() == "Enter" {
401 e.prevent_default();
402 submit_fn();
403 }
404 }
405 />
406 {move || {
407 let Some(err) = error.get() else {
408 return view! {}.into_any();
409 };
410 view! { <p class="dialog-error">{err}</p> }
411 .into_view()
412 .into_any()
413 }}
414 <div class="modal-actions">
415 <button class="btn" on:click=move |_| on_close.run(())>
416 {i18n::tr(i18n::k::CANCEL)}
417 </button>
418 <button
419 class="btn btn-primary"
420 disabled=move || busy.get()
421 on:click=move |_| submit_fn()
422 >
423 {submit}
424 </button>
425 </div>
426 </Modal>
427 }
428}
429
430// ---------------------------------------------------------------------------
431// Confirm
432// ---------------------------------------------------------------------------
433
434#[component]
435fn ConfirmDialog(
436 title: String,
437 message: String,
438 submit: String,
439 danger: bool,
440 on_submit: Callback<()>,
441 on_close: Callback<()>,
442) -> impl IntoView {
443 view! {
444 <Modal class="card" on_close=on_close>
445 <h2 class="modal-title">{title}</h2>
446 <p class="modal-message">{message}</p>
447 <div class="modal-actions">
448 <button class="btn" on:click=move |_| on_close.run(())>
449 {i18n::tr(i18n::k::CANCEL)}
450 </button>
451 <button
452 class=move || {
453 if danger {
454 "btn btn-danger".to_string()
455 } else {
456 "btn btn-primary".to_string()
457 }
458 }
459 on:click=move |_| {
460 on_submit.run(());
461 on_close.run(());
462 }
463 >
464 {submit}
465 </button>
466 </div>
467 </Modal>
468 }
469}
470
471// ---------------------------------------------------------------------------
472// Conflict (overwrite warning)
473// ---------------------------------------------------------------------------
474
475/// The list of colliding files shown by both conflict dialogs. Long lists
476/// are cut off with an "and N more" line.
477fn conflict_list(files: &[String]) -> impl IntoView + use<> {
478 let shown: Vec<String> = files.iter().take(20).cloned().collect();
479 let extra = files.len() - shown.len();
480 view! {
481 <ul class="conflict-list">
482 {shown.into_iter().map(|f| view! { <li>{f}</li> }).collect::<Vec<_>>()}
483 <Show when=move || { extra > 0 }>
484 <li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li>
485 </Show>
486 </ul>
487 }
488}
489
490#[component]
491fn ConflictDialog(
492 title: String,
493 files: Vec<String>,
494 on_submit: Callback<()>,
495 on_close: Callback<()>,
496) -> impl IntoView {
497 view! {
498 <Modal class="card" on_close=on_close>
499 <h2 class="modal-title">{title}</h2>
500 <p class="modal-message">
501 {i18n::tr(i18n::k::OVERWRITE_QUESTION)}
502 </p>
503 {conflict_list(&files)}
504 <div class="modal-actions">
505 <button class="btn" on:click=move |_| on_close.run(())>
506 {i18n::tr(i18n::k::CANCEL)}
507 </button>
508 <button
509 class="btn btn-danger"
510 on:click=move |_| {
511 on_submit.run(());
512 on_close.run(());
513 }
514 >
515 {i18n::tr(i18n::k::OVERWRITE)}
516 </button>
517 </div>
518 </Modal>
519 }
520}
521
522/// The upload pre-check dialog: Overwrite, Skip, Cancel, and "apply to all"
523/// (on by default) for files that appear during the transfer.
524#[component]
525fn UploadConflictDialog(
526 files: Vec<String>,
527 on_decide: Callback<Option<(bool, bool)>>,
528 on_close: Callback<()>,
529) -> impl IntoView {
530 let (apply_all, set_apply_all) = signal(true);
531 // The <dialog> close event fires after a decision too; answer once.
532 let answered = StoredValue::new(false);
533 let decide = move |overwrite: bool| {
534 answered.set_value(true);
535 on_decide.run(Some((overwrite, apply_all.get_untracked())));
536 on_close.run(());
537 };
538 let on_close = Callback::new(move |()| {
539 if !answered.get_value() {
540 answered.set_value(true);
541 on_decide.run(None);
542 }
543 on_close.run(());
544 });
545 view! {
546 <Modal class="card" on_close=on_close>
547 <h2 class="modal-title">{i18n::tr(i18n::k::SOME_FILES_EXIST)}</h2>
548 <p class="modal-message">
549 {i18n::tr(i18n::k::UPLOAD_CONFLICT_QUESTION)}
550 </p>
551 {conflict_list(&files)}
552 <label class="upload-conflict-all">
553 <input
554 type="checkbox"
555 checked=move || apply_all.get()
556 on:change=move |ev| set_apply_all.set(event_target_checked(&ev))
557 />
558 {i18n::tr(i18n::k::UPLOAD_APPLY_ALL)}
559 </label>
560 <div class="modal-actions">
561 <button class="btn" on:click=move |_| on_close.run(())>
562 {i18n::tr(i18n::k::CANCEL)}
563 </button>
564 <button class="btn" on:click=move |_| decide(false)>
565 {i18n::tr(i18n::k::UPLOAD_SKIP)}
566 </button>
567 <button class="btn btn-danger" on:click=move |_| decide(true)>
568 {i18n::tr(i18n::k::OVERWRITE)}
569 </button>
570 </div>
571 </Modal>
572 }
573}
574
575// ---------------------------------------------------------------------------
576// Picker (folder chooser)
577// ---------------------------------------------------------------------------
578
579/// The one entry of the admin picker's root pane: the server root, which
580/// every admin can browse read-only under [`api_types::ADMIN_ROOT`].
581pub fn admin_root() -> RootInfo {
582 RootInfo {
583 id: api_types::ADMIN_ROOT,
584 name: i18n::t(i18n::k::SERVER_ROOT).to_string(),
585 path: ".".to_string(),
586 mode: api_types::Mode::Rw,
587 }
588}
589
590/// Two panes: the roots on the left, the folder tree of the selected root
591/// on the right (breadcrumbs + subfolders). Clicking a subfolder descends;
592/// the breadcrumbs go back up and name the chosen folder.
593#[component]
594fn PickerDialog(
595 title: String,
596 confirm: String,
597 roots: Vec<RootInfo>,
598 root: i64,
599 dir: String,
600 for_write: bool,
601 on_pick: Callback<(i64, String)>,
602 on_close: Callback<()>,
603) -> impl IntoView {
604 let (sel_root, set_sel_root) = signal(root);
605 let (sel_dir, set_sel_dir) = signal(dir.clone());
606 let (entries, set_entries) = signal(Vec::<String>::new());
607 let (loading, set_loading) = signal(true);
608 let toast = use_context::<ToastMsg>().expect("toast context");
609
610 // Load the subfolder names of the selected directory whenever it changes.
611 // Bumped per load, so a slow response for a folder the user has already
612 // left is dropped instead of replacing the current one.
613 let load_gen = StoredValue::new(0u64);
614 Effect::new(move |_| {
615 let rid = sel_root.get();
616 let dir = sel_dir.get();
617 set_loading.set(true);
618 load_gen.update_value(|g| *g += 1);
619 let my_gen = load_gen.get_value();
620 spawn_local(async move {
621 let resp = api::list_files(rid, &dir).await;
622 if load_gen.try_get_value() != Some(my_gen) {
623 return;
624 }
625 let subs = match resp {
626 Ok(r) => r
627 .entries
628 .into_iter()
629 .filter(|e| e.is_dir)
630 .map(|e| e.name)
631 .collect(),
632 Err(e) => {
633 show_error(toast, e.to_string());
634 Vec::new()
635 }
636 };
637 set_entries.set(subs);
638 set_loading.set(false);
639 });
640 });
641
642 let by_id = Arc::new(roots.clone());
643 let root_name = {
644 let by_id = by_id.clone();
645 move |rid: i64| {
646 by_id
647 .iter()
648 .find(|r| r.id == rid)
649 .map(|r| r.name.clone())
650 .unwrap_or_default()
651 }
652 };
653 let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable());
654 let descend = move |name: String| {
655 let cur = sel_dir.get();
656 set_sel_dir.set(if cur.is_empty() {
657 name
658 } else {
659 format!("{cur}/{name}")
660 });
661 };
662
663 view! {
664 <Modal class="card picker" on_close=on_close>
665 <h2 class="modal-title">{title}</h2>
666
667 <div class="picker-body">
668 <div class="picker-roots">
669 {roots.iter().map(|r| {
670 let rid = r.id;
671 let ro = !r.mode.is_writable();
672 let name = r.name.clone();
673 view! {
674 <button
675 class="picker-root"
676 class:active=move || rid == sel_root.get()
677 class:ro=ro
678 title=move || if ro { i18n::t(i18n::k::READ_ONLY_HINT) } else { "" }
679 on:click=move |_| {
680 set_sel_root.set(rid);
681 set_sel_dir.set(String::new());
682 }
683 >
684 <Icon name=IconName::Folder class="picker-root-ic".to_string()/>
685 <span class="picker-root-name">{name}</span>
686 </button>
687 }
688 }).collect::<Vec<_>>()}
689 </div>
690
691 <div class="picker-main">
692 <div class="crumbs picker-crumbs">
693 <span
694 class="crumb"
695 class:current=move || sel_dir.get().is_empty()
696 on:click=move |_| set_sel_dir.set(String::new())
697 >
698 {move || root_name(sel_root.get())}
699 </span>
700 {move || {
701 let segs = dir_segs(&sel_dir.get());
702 segs
703 .iter()
704 .enumerate()
705 .map(|(i, seg)| {
706 let seg = seg.clone();
707 let prefix: String = segs[..=i].join("/");
708 let is_last = i == segs.len() - 1;
709 view! {
710 <span class="crumb-sep">"/"</span>
711 <span
712 class="crumb"
713 class:current=is_last
714 on:click=move |_| set_sel_dir.set(prefix.clone())
715 >
716 {seg}
717 </span>
718 }
719 })
720 .collect::<Vec<_>>()
721 }}
722 </div>
723
724 <div class="picker-entries">
725 {move || {
726 let es = entries.get();
727 if loading.get() {
728 return view! {
729 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
730 }
731 .into_view()
732 .into_any();
733 }
734 if es.is_empty() {
735 return view! {
736 <div class="picker-empty muted">
737 <Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
738 <span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
739 </div>
740 }
741 .into_view()
742 .into_any();
743 }
744 es.iter()
745 .map(|name| {
746 let name = name.clone();
747 let name2 = name.clone();
748 view! {
749 <div class="picker-row" on:click=move |_| descend(name2.clone())>
750 <Icon name=IconName::Folder class="row-icon".to_string()/>
751 <span class="row-name">{name}</span>
752 <span class="picker-chev"></span>
753 </div>
754 }
755 })
756 .collect::<Vec<_>>()
757 .into_view()
758 .into_any()
759 }}
760 </div>
761 </div>
762 </div>
763
764 <div class="modal-actions">
765 <button class="btn" on:click=move |_| on_close.run(())>
766 {i18n::tr(i18n::k::CANCEL)}
767 </button>
768 <button
769 class="btn btn-primary"
770 disabled=move || {
771 let rid = sel_root.get();
772 for_write && (!root_writable(rid) || (rid == root && sel_dir.get() == dir))
773 }
774 on:click=move |_| {
775 on_pick.run((sel_root.get(), sel_dir.get()));
776 on_close.run(());
777 }
778 >
779 {confirm}
780 </button>
781 </div>
782 </Modal>
783 }
784}
785
786// ---------------------------------------------------------------------------
787// Download format (folder archive choice)
788// ---------------------------------------------------------------------------
789
790#[component]
791fn DownloadFormatDialog(
792 name: String,
793 root_id: i64,
794 path: String,
795 on_close: Callback<()>,
796) -> impl IntoView {
797 let toast = use_context::<ToastMsg>().expect("toast context");
798 let name_title = name.clone();
799 let formats: [(&str, &str, &str); 4] = [
800 ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
801 ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
802 ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
803 ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
804 ];
805 view! {
806 <Modal class="card" on_close=on_close>
807 <h2 class="modal-title">
808 {move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
809 </h2>
810 <p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
811 <div class="dl-formats">
812 {formats.iter().map(|(id, label, ext)| {
813 let (id, label, ext) = (*id, *label, *ext);
814 let path = path.clone();
815 let name = name.clone();
816 view! {
817 <button
818 class="btn btn-primary dl-fmt"
819 on:click=move |_| {
820 let url = api::download_url(root_id, &path, Some(id));
821 let fname = format!("{name}{ext}");
822 api::trigger_download(&url, &fname);
823 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
824 on_close.run(());
825 }
826 >
827 {label}
828 </button>
829 }
830 }).collect::<Vec<_>>()}
831 </div>
832 <div class="modal-actions">
833 <button class="btn" on:click=move |_| on_close.run(())>
834 {i18n::tr(i18n::k::CANCEL)}
835 </button>
836 </div>
837 </Modal>
838 }
839}
840
841// ---------------------------------------------------------------------------
842// Item info
843// ---------------------------------------------------------------------------
844
845#[component]
846fn InfoDialog(
847 entry: Entry,
848 root_name: String,
849 rel_path: String,
850 on_close: Callback<()>,
851) -> impl IntoView {
852 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
853 let is_dir = entry.is_dir;
854 let size = if is_dir {
855 "—".to_string()
856 } else {
857 crate::util::format_size(entry.size)
858 };
859 let modified = crate::util::format_date(&entry.mtime);
860 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
861 let location = if parent.is_empty() {
862 format!("{root_name}/")
863 } else {
864 format!("{root_name}/{parent}")
865 };
866 view! {
867 <Modal class="card" on_close=on_close>
868 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
869 <dl class="info-list">
870 <dt>{i18n::tr(i18n::k::NAME)}</dt>
871 <dd>{entry.name.clone()}</dd>
872 <dt>{i18n::tr(i18n::k::TYPE)}</dt>
873 <dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
874 <dt>{i18n::tr(i18n::k::SIZE)}</dt>
875 <dd>{size}</dd>
876 <dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
877 <dd>{modified}</dd>
878 <dt>{i18n::tr(i18n::k::LOCATION)}</dt>
879 <dd>{location}</dd>
880 </dl>
881 <div class="modal-actions">
882 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
883 {i18n::tr(i18n::k::CLOSE)}
884 </button>
885 </div>
886 </Modal>
887 }
888}
889