file_view.rs
⎇
Raw
1//! Full-page file view (opened files take over the whole content area).
2//!
3//! The top bar doubles as the file's header: the file name where the brand
4//! was, with save/close on the right. Used by both the signed-in shell and
5//! the public share page.
6
7use crate::i18n;
8use api_types::FileKind;
9use leptos::prelude::*;
10
11use crate::api;
12use crate::components::icon::Icon;
13use crate::components::toast::ToastMsg;
14use crate::editor::{EditTarget, Editor};
15use crate::icons::{IconName, icon_for};
16use crate::preview::{Preview, PreviewKind, PreviewTarget};
17
18/// A file that is open in the full-page view.
19#[derive(Clone)]
20pub enum FileView {
21 /// Image / PDF / video / audio.
22 Preview(PreviewTarget, PreviewKind),
23 /// Text file; `EditTarget.readonly` decides whether it is editable.
24 Editor(EditTarget),
25 /// No built-in viewer for this kind: show a message + download button.
26 Unsupported(UnsupportedTarget),
27}
28
29/// A file the app cannot preview (binary, archive, ...).
30#[derive(Clone)]
31pub struct UnsupportedTarget {
32 pub root_id: i64,
33 /// Path relative to the root.
34 pub path: String,
35 /// Display name.
36 pub name: String,
37 /// Sniffed kind, for the icon.
38 pub kind: FileKind,
39}
40
41impl FileView {
42 pub fn name(&self) -> &str {
43 match self {
44 FileView::Preview(t, _) => &t.name,
45 FileView::Editor(t) => &t.name,
46 FileView::Unsupported(t) => &t.name,
47 }
48 }
49
50 /// The glyph shown next to the name in the top bar.
51 pub fn icon(&self) -> IconName {
52 match self {
53 FileView::Preview(_t, kind) => match kind {
54 PreviewKind::Image => IconName::Image,
55 PreviewKind::Pdf => IconName::Pdf,
56 PreviewKind::Video => IconName::Video,
57 PreviewKind::Audio => IconName::Audio,
58 },
59 FileView::Editor(t) => icon_for(FileKind::Text, &t.name),
60 FileView::Unsupported(t) => icon_for(t.kind, &t.name),
61 }
62 }
63
64 /// True when the file is shown read-only (a text file in a read-only
65 /// root). Media is always "read-only" in the sense that there is no
66 /// save action; only the editor exposes the badge.
67 pub fn readonly(&self) -> bool {
68 matches!(self, FileView::Editor(t) if t.readonly)
69 }
70}
71
72/// Top-bar replacement for the brand: file icon + name (with a dirty dot)
73/// and, for read-only text files, a badge.
74#[component]
75pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
76 let name = view.name().to_string();
77 let icon = view.icon();
78 let readonly = view.readonly();
79 view! {
80 <div class="file-title">
81 <Icon name=icon class="file-title-icon".to_string()/>
82 <span class="file-title-name">
83 {move || {
84 let dot = if dirty.get() { " •" } else { "" };
85 format!("{name}{dot}")
86 }}
87 </span>
88 {move || {
89 if readonly {
90 view! { <span class="badge">{i18n::t(i18n::k::READ_ONLY_BADGE)}</span> }.into_view().into_any()
91 } else {
92 view! {}.into_any()
93 }
94 }}
95 </div>
96 }
97}
98
99/// Top-bar actions: Save (only present while an editable file has
100/// registered its save callback) and close.
101#[component]
102pub fn FileViewActions(
103 saving: ReadSignal<bool>,
104 dirty: ReadSignal<bool>,
105 save_cb: ReadSignal<Option<Callback<()>>>,
106 /// The editor registers its close request here (it asks about unsaved
107 /// changes first); other views close directly.
108 close_cb: ReadSignal<Option<Callback<()>>>,
109 /// False for file shares, where there is nowhere to close back to.
110 show_close: bool,
111 on_close: Callback<()>,
112) -> impl IntoView {
113 view! {
114 <div class="file-actions">
115 {move || match save_cb.get() {
116 Some(cb) => view! {
117 <button
118 class="btn btn-sm btn-primary"
119 disabled=move || !dirty.get() || saving.get()
120 on:click=move |_| cb.run(())
121 >
122 {move || {
123 if saving.get() {
124 i18n::t(i18n::k::SAVING).to_string()
125 } else {
126 i18n::t(i18n::k::SAVE).to_string()
127 }
128 }}
129 </button>
130 }
131 .into_view()
132 .into_any(),
133 None => view! {}.into_any(),
134 }}
135 <Show when=move || show_close>
136 <button
137 class="icon-btn file-close"
138 title={i18n::tr(i18n::k::CLOSE_ESC)}
139 aria-label={i18n::tr(i18n::k::CLOSE)}
140 on:click=move |_| {
141 if let Some(cb) = close_cb.get() {
142 cb.run(());
143 } else {
144 on_close.run(());
145 }
146 }
147 >
148 <Icon name=IconName::Close class="ic-btn".to_string()/>
149 </button>
150 </Show>
151 </div>
152 }
153}
154
155/// The full-page content: preview, editor, or the "no preview" fallback.
156#[component]
157pub fn FileViewContent(
158 view: FileView,
159 register_save: WriteSignal<Option<Callback<()>>>,
160 register_close: WriteSignal<Option<Callback<()>>>,
161 dirty: ReadSignal<bool>,
162 set_dirty: WriteSignal<bool>,
163 set_saving: WriteSignal<bool>,
164 close: Callback<()>,
165 toast: ToastMsg,
166 /// Re-fetch the (hidden, still mounted) browser after a save.
167 refresh: Callback<()>,
168) -> impl IntoView {
169 match view {
170 FileView::Preview(target, kind) => view! {
171 <div class="file-view">
172 <Preview target=target kind=kind/>
173 </div>
174 }
175 .into_view()
176 .into_any(),
177 FileView::Editor(target) => view! {
178 <div class="file-view">
179 <Editor
180 target=target
181 register_save=register_save
182 register_close=register_close
183 dirty=dirty
184 set_dirty=set_dirty
185 set_saving=set_saving
186 close=close
187 toast=toast
188 refresh=refresh
189 />
190 </div>
191 }
192 .into_view()
193 .into_any(),
194 FileView::Unsupported(target) => view! {
195 <div class="file-view">
196 <NoPreview target=target/>
197 </div>
198 }
199 .into_view()
200 .into_any(),
201 }
202}
203
204/// Shown for files with no built-in viewer: a message and a download button.
205#[component]
206fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
207 let icon = icon_for(target.kind, &target.name);
208 let name = target.name.clone();
209 let t = target;
210 view! {
211 <div class="file-nopreview">
212 <div class="empty-state">
213 <Icon name=icon class="empty-glyph".to_string()/>
214 <h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
215 <p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
216 <button
217 class="btn btn-primary"
218 on:click=move |_| {
219 let url = api::download_url(t.root_id, &t.path, None);
220 api::trigger_download(&url, &name);
221 }
222 >
223 <Icon name=IconName::Download class="ic-btn".to_string()/>
224 {i18n::tr(i18n::k::DOWNLOAD)}
225 </button>
226 </div>
227 </div>
228 }
229}
230