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