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_dirs(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.entries.into_iter().map(|e| e.name).collect(),
627 Err(e) => {
628 show_error(toast, e.to_string());
629 Vec::new()
630 }
631 };
632 set_entries.set(subs);
633 set_loading.set(false);
634 });
635 });
636
637 let by_id = Arc::new(roots.clone());
638 let root_name = {
639 let by_id = by_id.clone();
640 move |rid: i64| {
641 by_id
642 .iter()
643 .find(|r| r.id == rid)
644 .map(|r| r.name.clone())
645 .unwrap_or_default()
646 }
647 };
648 let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable());
649 let descend = move |name: String| {
650 let cur = sel_dir.get();
651 set_sel_dir.set(if cur.is_empty() {
652 name
653 } else {
654 format!("{cur}/{name}")
655 });
656 };
657
658 view! {
659 <Modal class="card picker" on_close=on_close>
660 <h2 class="modal-title">{title}</h2>
661
662 <div class="picker-body">
663 <div class="picker-roots">
664 {roots.iter().map(|r| {
665 let rid = r.id;
666 let ro = !r.mode.is_writable();
667 let name = r.name.clone();
668 view! {
669 <button
670 class="picker-root"
671 class:active=move || rid == sel_root.get()
672 class:ro=ro
673 title=move || if ro { i18n::t(i18n::k::READ_ONLY_HINT) } else { "" }
674 on:click=move |_| {
675 set_sel_root.set(rid);
676 set_sel_dir.set(String::new());
677 }
678 >
679 <Icon name=IconName::Folder class="picker-root-ic".to_string()/>
680 <span class="picker-root-name">{name}</span>
681 </button>
682 }
683 }).collect::<Vec<_>>()}
684 </div>
685
686 <div class="picker-main">
687 <div class="crumbs picker-crumbs">
688 <span
689 class="crumb"
690 class:current=move || sel_dir.get().is_empty()
691 on:click=move |_| set_sel_dir.set(String::new())
692 >
693 {move || root_name(sel_root.get())}
694 </span>
695 {move || {
696 let segs = dir_segs(&sel_dir.get());
697 segs
698 .iter()
699 .enumerate()
700 .map(|(i, seg)| {
701 let seg = seg.clone();
702 let prefix: String = segs[..=i].join("/");
703 let is_last = i == segs.len() - 1;
704 view! {
705 <span class="crumb-sep">"/"</span>
706 <span
707 class="crumb"
708 class:current=is_last
709 on:click=move |_| set_sel_dir.set(prefix.clone())
710 >
711 {seg}
712 </span>
713 }
714 })
715 .collect::<Vec<_>>()
716 }}
717 </div>
718
719 <div class="picker-entries">
720 {move || {
721 let es = entries.get();
722 if loading.get() {
723 return view! {
724 <p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
725 }
726 .into_view()
727 .into_any();
728 }
729 if es.is_empty() {
730 return view! {
731 <div class="picker-empty muted">
732 <Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
733 <span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
734 </div>
735 }
736 .into_view()
737 .into_any();
738 }
739 es.iter()
740 .map(|name| {
741 let name = name.clone();
742 let name2 = name.clone();
743 view! {
744 <div class="picker-row" on:click=move |_| descend(name2.clone())>
745 <Icon name=IconName::Folder class="row-icon".to_string()/>
746 <span class="row-name">{name}</span>
747 <span class="picker-chev"></span>
748 </div>
749 }
750 })
751 .collect::<Vec<_>>()
752 .into_view()
753 .into_any()
754 }}
755 </div>
756 </div>
757 </div>
758
759 <div class="modal-actions">
760 <button class="btn" on:click=move |_| on_close.run(())>
761 {i18n::tr(i18n::k::CANCEL)}
762 </button>
763 <button
764 class="btn btn-primary"
765 disabled=move || {
766 let rid = sel_root.get();
767 for_write && (!root_writable(rid) || (rid == root && sel_dir.get() == dir))
768 }
769 on:click=move |_| {
770 on_pick.run((sel_root.get(), sel_dir.get()));
771 on_close.run(());
772 }
773 >
774 {confirm}
775 </button>
776 </div>
777 </Modal>
778 }
779}
780
781// ---------------------------------------------------------------------------
782// Download format (folder archive choice)
783// ---------------------------------------------------------------------------
784
785#[component]
786fn DownloadFormatDialog(
787 name: String,
788 root_id: i64,
789 path: String,
790 on_close: Callback<()>,
791) -> impl IntoView {
792 let toast = use_context::<ToastMsg>().expect("toast context");
793 let name_title = name.clone();
794 let formats: [(&str, &str, &str); 4] = [
795 ("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
796 ("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
797 ("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
798 ("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
799 ];
800 view! {
801 <Modal class="card" on_close=on_close>
802 <h2 class="modal-title">
803 {move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
804 </h2>
805 <p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
806 <div class="dl-formats">
807 {formats.iter().map(|(id, label, ext)| {
808 let (id, label, ext) = (*id, *label, *ext);
809 let path = path.clone();
810 let name = name.clone();
811 view! {
812 <button
813 class="btn btn-primary dl-fmt"
814 on:click=move |_| {
815 let url = api::download_url(root_id, &path, Some(id));
816 let fname = format!("{name}{ext}");
817 api::trigger_download(&url, &fname);
818 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
819 on_close.run(());
820 }
821 >
822 {label}
823 </button>
824 }
825 }).collect::<Vec<_>>()}
826 </div>
827 <div class="modal-actions">
828 <button class="btn" on:click=move |_| on_close.run(())>
829 {i18n::tr(i18n::k::CANCEL)}
830 </button>
831 </div>
832 </Modal>
833 }
834}
835
836// ---------------------------------------------------------------------------
837// Item info
838// ---------------------------------------------------------------------------
839
840#[component]
841fn InfoDialog(
842 entry: Entry,
843 root_name: String,
844 rel_path: String,
845 on_close: Callback<()>,
846) -> impl IntoView {
847 // `rel_path` is the item's full path relative to the root (e.g. "docs/a.txt").
848 let is_dir = entry.is_dir;
849 let size = if is_dir {
850 "—".to_string()
851 } else {
852 crate::util::format_size(entry.size)
853 };
854 let modified = crate::util::format_date(&entry.mtime);
855 let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
856 let location = if parent.is_empty() {
857 format!("{root_name}/")
858 } else {
859 format!("{root_name}/{parent}")
860 };
861 view! {
862 <Modal class="card" on_close=on_close>
863 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
864 <dl class="info-list">
865 <dt>{i18n::tr(i18n::k::NAME)}</dt>
866 <dd>{entry.name.clone()}</dd>
867 <dt>{i18n::tr(i18n::k::TYPE)}</dt>
868 <dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
869 <dt>{i18n::tr(i18n::k::SIZE)}</dt>
870 <dd>{size}</dd>
871 <dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
872 <dd>{modified}</dd>
873 <dt>{i18n::tr(i18n::k::LOCATION)}</dt>
874 <dd>{location}</dd>
875 </dl>
876 <div class="modal-actions">
877 <button class="btn btn-primary" on:click=move |_| on_close.run(())>
878 {i18n::tr(i18n::k::CLOSE)}
879 </button>
880 </div>
881 </Modal>
882 }
883}
884