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 on_close: Callback<()>,
109) -> impl IntoView {
110 view! {
111 <div class="file-actions">
112 {move || match save_cb.get() {
113 Some(cb) => view! {
114 <button
115 class="btn btn-sm btn-primary"
116 disabled=move || !dirty.get() || saving.get()
117 on:click=move |_| cb.run(())
118 >
119 {move || if saving.get() { "Saving…".to_string() } else { "Save".to_string() }}
120 </button>
121 }
122 .into_view()
123 .into_any(),
124 None => view! {}.into_any(),
125 }}
126 <button
127 class="icon-btn file-close"
128 title="Close (Esc)"
129 aria-label="Close"
130 on:click=move |_| {
131 if let Some(cb) = close_cb.get() {
132 cb.run(());
133 } else {
134 on_close.run(());
135 }
136 }
137 >
138 <Icon name=IconName::Close class="ic-btn".to_string()/>
139 </button>
140 </div>
141 }
142}
143
144/// The full-page content: preview, editor, or the "no preview" fallback.
145#[component]
146pub fn FileViewContent(
147 view: FileView,
148 register_save: WriteSignal<Option<Callback<()>>>,
149 register_close: WriteSignal<Option<Callback<()>>>,
150 dirty: ReadSignal<bool>,
151 set_dirty: WriteSignal<bool>,
152 set_saving: WriteSignal<bool>,
153 close: Callback<()>,
154 toast: ToastMsg,
155 /// Re-fetch the (hidden, still mounted) browser after a save.
156 refresh: Callback<()>,
157) -> impl IntoView {
158 match view {
159 FileView::Preview(target, kind) => view! {
160 <div class="file-view">
161 <Preview target=target kind=kind/>
162 </div>
163 }
164 .into_view()
165 .into_any(),
166 FileView::Editor(target) => view! {
167 <div class="file-view">
168 <Editor
169 target=target
170 register_save=register_save
171 register_close=register_close
172 dirty=dirty
173 set_dirty=set_dirty
174 set_saving=set_saving
175 close=close
176 toast=toast
177 refresh=refresh
178 />
179 </div>
180 }
181 .into_view()
182 .into_any(),
183 FileView::Unsupported(target) => view! {
184 <div class="file-view">
185 <NoPreview target=target/>
186 </div>
187 }
188 .into_view()
189 .into_any(),
190 }
191}
192
193/// Shown for files with no built-in viewer: a message and a download button.
194#[component]
195fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
196 let icon = icon_for(target.kind, &target.name);
197 let name = target.name.clone();
198 let t = target;
199 view! {
200 <div class="file-nopreview">
201 <div class="empty-state">
202 <Icon name=icon class="empty-glyph".to_string()/>
203 <h3>"No preview available"</h3>
204 <p class="muted">{format!("The app can't display “{name}” here.")}</p>
205 <button
206 class="btn btn-primary"
207 on:click=move |_| {
208 let url = api::download_url(t.root_id, &t.path, None);
209 api::trigger_download(&url, &name);
210 }
211 >
212 <Icon name=IconName::Download class="ic-btn".to_string()/>
213 "Download"
214 </button>
215 </div>
216 </div>
217 }
218}
219