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, show};
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 /// Root the file lives in (for the download link).
65 pub fn root_id(&self) -> i64 {
66 match self {
67 FileView::Preview(t, _) => t.root_id,
68 FileView::Editor(t) => t.root_id,
69 FileView::Unsupported(t) => t.root_id,
70 }
71 }
72
73 /// Path relative to the root (for the download link).
74 pub fn path(&self) -> &str {
75 match self {
76 FileView::Preview(t, _) => &t.path,
77 FileView::Editor(t) => &t.path,
78 FileView::Unsupported(t) => &t.path,
79 }
80 }
81
82 /// True when the file is shown read-only (a text file in a read-only
83 /// root). Media is always "read-only" in the sense that there is no
84 /// save action; only the editor exposes the badge.
85 pub fn readonly(&self) -> bool {
86 matches!(self, FileView::Editor(t) if t.readonly)
87 }
88}
89
90/// Top-bar replacement for the brand: file icon + name (with a dirty dot)
91/// and, for read-only text files, a badge.
92#[component]
93pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
94 let name = view.name().to_string();
95 let icon = view.icon();
96 let readonly = view.readonly();
97 view! {
98 <div class="file-title">
99 <Icon name=icon class="file-title-icon".to_string()/>
100 <span class="file-title-name">
101 {move || {
102 let dot = if dirty.get() { " •" } else { "" };
103 format!("{name}{dot}")
104 }}
105 </span>
106 {move || {
107 if readonly {
108 view! { <span class="badge">{i18n::t(i18n::k::READ_ONLY_BADGE)}</span> }.into_view().into_any()
109 } else {
110 view! {}.into_any()
111 }
112 }}
113 </div>
114 }
115}
116
117/// Top-bar actions: Save (only present while an editable file has
118/// registered its save callback), Download and close.
119#[component]
120pub fn FileViewActions(
121 /// The open file — the download button targets it.
122 view: FileView,
123 saving: ReadSignal<bool>,
124 dirty: ReadSignal<bool>,
125 save_cb: ReadSignal<Option<Callback<()>>>,
126 /// The editor registers its close request here (it asks about unsaved
127 /// changes first); other views close directly.
128 close_cb: ReadSignal<Option<Callback<()>>>,
129 /// Whether the close button is shown at all: closing means "back to
130 /// the listing", so it is hidden when there is nothing to go back to
131 /// (file shares).
132 show_close: bool,
133 on_close: Callback<()>,
134 toast: ToastMsg,
135) -> impl IntoView {
136 // Serves the file from disk (for an open editor: the saved version,
137 // not the working copy). Built once per open file — nothing about it
138 // is reactive.
139 let download_btn = {
140 let dl = view;
141 view! {
142 <button
143 class="icon-btn file-download"
144 title={i18n::tr(i18n::k::DOWNLOAD)}
145 aria-label={i18n::tr(i18n::k::DOWNLOAD)}
146 on:click=move |_| {
147 let url = api::download_url(dl.root_id(), dl.path(), None);
148 api::trigger_download(&url, dl.name());
149 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
150 }
151 >
152 <Icon name=IconName::Download class="ic-btn".to_string()/>
153 </button>
154 }
155 };
156 view! {
157 <div class="file-actions">
158 {move || match save_cb.get() {
159 Some(cb) => view! {
160 <button
161 class="btn btn-sm btn-primary"
162 disabled=move || !dirty.get() || saving.get()
163 on:click=move |_| cb.run(())
164 >
165 {move || {
166 if saving.get() {
167 i18n::t(i18n::k::SAVING).to_string()
168 } else {
169 i18n::t(i18n::k::SAVE).to_string()
170 }
171 }}
172 </button>
173 }
174 .into_view()
175 .into_any(),
176 None => view! {}.into_any(),
177 }}
178 {download_btn}
179 <Show when=move || show_close>
180 <button
181 class="icon-btn file-close"
182 title={i18n::tr(i18n::k::CLOSE_ESC)}
183 aria-label={i18n::tr(i18n::k::CLOSE)}
184 on:click=move |_| {
185 if let Some(cb) = close_cb.get() {
186 cb.run(());
187 } else {
188 on_close.run(());
189 }
190 }
191 >
192 <Icon name=IconName::Close class="ic-btn".to_string()/>
193 </button>
194 </Show>
195 </div>
196 }
197}
198
199/// The full-page content: preview, editor, or the "no preview" fallback.
200#[component]
201pub fn FileViewContent(
202 view: FileView,
203 register_save: WriteSignal<Option<Callback<()>>>,
204 register_close: WriteSignal<Option<Callback<()>>>,
205 dirty: ReadSignal<bool>,
206 set_dirty: WriteSignal<bool>,
207 set_saving: WriteSignal<bool>,
208 close: Callback<()>,
209 toast: ToastMsg,
210 /// Re-fetch the (hidden, still mounted) browser after a save.
211 refresh: Callback<()>,
212) -> impl IntoView {
213 match view {
214 FileView::Preview(target, kind) => view! {
215 <div class="file-view">
216 <Preview target=target kind=kind/>
217 </div>
218 }
219 .into_view()
220 .into_any(),
221 FileView::Editor(target) => view! {
222 <div class="file-view">
223 <Editor
224 target=target
225 register_save=register_save
226 register_close=register_close
227 dirty=dirty
228 set_dirty=set_dirty
229 set_saving=set_saving
230 close=close
231 toast=toast
232 refresh=refresh
233 />
234 </div>
235 }
236 .into_view()
237 .into_any(),
238 FileView::Unsupported(target) => view! {
239 <div class="file-view">
240 <NoPreview target=target/>
241 </div>
242 }
243 .into_view()
244 .into_any(),
245 }
246}
247
248/// Shown for files with no built-in viewer: a message and a download button.
249#[component]
250fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
251 let icon = icon_for(target.kind, &target.name);
252 let name = target.name.clone();
253 let t = target;
254 view! {
255 <div class="file-nopreview">
256 <div class="empty-state">
257 <Icon name=icon class="empty-glyph".to_string()/>
258 <h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
259 <p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
260 <button
261 class="btn btn-primary"
262 on:click=move |_| {
263 let url = api::download_url(t.root_id, &t.path, None);
264 api::trigger_download(&url, &name);
265 }
266 >
267 <Icon name=IconName::Download class="ic-btn".to_string()/>
268 {i18n::tr(i18n::k::DOWNLOAD)}
269 </button>
270 </div>
271 </div>
272 }
273}
274