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 /// False for file shares, where there is nowhere to close back to.
130 show_close: bool,
131 on_close: Callback<()>,
132 toast: ToastMsg,
133) -> impl IntoView {
134 // Serves the file from disk (for an open editor: the saved version,
135 // not the working copy). Built once per open file — nothing about it
136 // is reactive.
137 let download_btn = {
138 let dl = view;
139 view! {
140 <button
141 class="icon-btn file-download"
142 title={i18n::tr(i18n::k::DOWNLOAD)}
143 aria-label={i18n::tr(i18n::k::DOWNLOAD)}
144 on:click=move |_| {
145 let url = api::download_url(dl.root_id(), dl.path(), None);
146 api::trigger_download(&url, dl.name());
147 show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
148 }
149 >
150 <Icon name=IconName::Download class="ic-btn".to_string()/>
151 </button>
152 }
153 };
154 view! {
155 <div class="file-actions">
156 {move || match save_cb.get() {
157 Some(cb) => view! {
158 <button
159 class="btn btn-sm btn-primary"
160 disabled=move || !dirty.get() || saving.get()
161 on:click=move |_| cb.run(())
162 >
163 {move || {
164 if saving.get() {
165 i18n::t(i18n::k::SAVING).to_string()
166 } else {
167 i18n::t(i18n::k::SAVE).to_string()
168 }
169 }}
170 </button>
171 }
172 .into_view()
173 .into_any(),
174 None => view! {}.into_any(),
175 }}
176 {download_btn}
177 <Show when=move || show_close>
178 <button
179 class="icon-btn file-close"
180 title={i18n::tr(i18n::k::CLOSE_ESC)}
181 aria-label={i18n::tr(i18n::k::CLOSE)}
182 on:click=move |_| {
183 if let Some(cb) = close_cb.get() {
184 cb.run(());
185 } else {
186 on_close.run(());
187 }
188 }
189 >
190 <Icon name=IconName::Close class="ic-btn".to_string()/>
191 </button>
192 </Show>
193 </div>
194 }
195}
196
197/// The full-page content: preview, editor, or the "no preview" fallback.
198#[component]
199pub fn FileViewContent(
200 view: FileView,
201 register_save: WriteSignal<Option<Callback<()>>>,
202 register_close: WriteSignal<Option<Callback<()>>>,
203 dirty: ReadSignal<bool>,
204 set_dirty: WriteSignal<bool>,
205 set_saving: WriteSignal<bool>,
206 close: Callback<()>,
207 toast: ToastMsg,
208 /// Re-fetch the (hidden, still mounted) browser after a save.
209 refresh: Callback<()>,
210) -> impl IntoView {
211 match view {
212 FileView::Preview(target, kind) => view! {
213 <div class="file-view">
214 <Preview target=target kind=kind/>
215 </div>
216 }
217 .into_view()
218 .into_any(),
219 FileView::Editor(target) => view! {
220 <div class="file-view">
221 <Editor
222 target=target
223 register_save=register_save
224 register_close=register_close
225 dirty=dirty
226 set_dirty=set_dirty
227 set_saving=set_saving
228 close=close
229 toast=toast
230 refresh=refresh
231 />
232 </div>
233 }
234 .into_view()
235 .into_any(),
236 FileView::Unsupported(target) => view! {
237 <div class="file-view">
238 <NoPreview target=target/>
239 </div>
240 }
241 .into_view()
242 .into_any(),
243 }
244}
245
246/// Shown for files with no built-in viewer: a message and a download button.
247#[component]
248fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
249 let icon = icon_for(target.kind, &target.name);
250 let name = target.name.clone();
251 let t = target;
252 view! {
253 <div class="file-nopreview">
254 <div class="empty-state">
255 <Icon name=icon class="empty-glyph".to_string()/>
256 <h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
257 <p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
258 <button
259 class="btn btn-primary"
260 on:click=move |_| {
261 let url = api::download_url(t.root_id, &t.path, None);
262 api::trigger_download(&url, &name);
263 }
264 >
265 <Icon name=IconName::Download class="ic-btn".to_string()/>
266 {i18n::tr(i18n::k::DOWNLOAD)}
267 </button>
268 </div>
269 </div>
270 }
271}
272