share_page.rs
⎇
Raw
1//! Public share page (milestone 6): renders a shared item without login.
2
3use crate::i18n;
4use api_types::FileKind;
5use leptos::prelude::*;
6use wasm_bindgen_futures::spawn_local;
7
8use crate::api::{self, Entry, Me, Mode, RootInfo, ShareInfo, UserInfo};
9use crate::components::logo::Logo;
10use crate::components::toast::{ToastView, provide_toast};
11use crate::editor::EditTarget;
12use crate::preview::{PreviewTarget, preview_kind};
13use crate::router::Location;
14use crate::views::browser::{Browser, SelectionActions};
15use crate::views::dialogs::{Dialog, DialogView};
16use crate::views::file_view::{
17 FileView, FileViewActions, FileViewContent, FileViewTitle, UnsupportedTarget,
18};
19
20#[derive(Clone, PartialEq)]
21enum SharePageState {
22 Loading,
23 NotFound,
24 Expired,
25 Active,
26}
27
28/// The full-page view for a shared *file*: the share page *is* the file.
29/// The file sits at the (empty) path of its synthetic root.
30fn file_view_for_share(i: &ShareInfo) -> Option<FileView> {
31 let kind = i.kind?;
32 let root_id = i.root_id;
33 let name = i.name.clone();
34 let path = String::new();
35 match kind {
36 FileKind::Text => Some(FileView::Editor(EditTarget {
37 root_id,
38 path,
39 name,
40 readonly: !i.writable,
41 })),
42 k if preview_kind(k).is_some() => Some(FileView::Preview(
43 PreviewTarget {
44 root_id,
45 path,
46 name,
47 },
48 preview_kind(k).unwrap(),
49 )),
50 k => Some(FileView::Unsupported(UnsupportedTarget {
51 root_id,
52 path,
53 name,
54 kind: k,
55 })),
56 }
57}
58
59#[component]
60pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
61 let toast = provide_toast();
62 let (me, set_me) = signal(Option::<Me>::None);
63 let (info, set_info) = signal(Option::<ShareInfo>::None);
64 let (state, set_state) = signal(SharePageState::Loading);
65
66 // Resolve the share once per token and scope all file API calls to it.
67 {
68 let tok = token.clone();
69 let set_me2 = set_me;
70 let set_info2 = set_info;
71 let set_state2 = set_state;
72 spawn_local(async move {
73 api::set_share_token(Some(&tok));
74 match api::resolve_share(&tok).await {
75 Ok(i) => {
76 let mode = if i.writable { Mode::Rw } else { Mode::Ro };
77 let me_val = Me {
78 first_boot: false,
79 user: Some(UserInfo {
80 id: i.id,
81 name: "shared".to_string(),
82 is_admin: false,
83 single_click_open: false,
84 language: None,
85 }),
86 roots: vec![RootInfo {
87 id: i.root_id,
88 name: i.name.clone(),
89 path: "/".to_string(),
90 mode,
91 }],
92 allow_writable_shares: false,
93 };
94 set_me2.set(Some(me_val));
95 set_info2.set(Some(i));
96 set_state2.set(SharePageState::Active);
97 }
98 Err(e) => {
99 let expired = e.status() == Some(410);
100 set_state2.set(if expired {
101 SharePageState::Expired
102 } else {
103 SharePageState::NotFound
104 });
105 }
106 }
107 });
108 on_cleanup(move || api::set_share_token(None));
109 }
110
111 // A file opened in the full-page view; the top bar doubles as its header.
112 let (file_view, set_file_view) = signal(Option::<FileView>::None);
113 // The open editor stores its save callback here for the top-bar button.
114 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
115 // The open editor's guarded close request (unsaved-changes prompt).
116 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
117 let (dirty, set_dirty) = signal(false);
118 let (saving, set_saving) = signal(false);
119 // The browser's fetch, exposed so the editor can re-list after a save.
120 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
121 // The single selected entry in the file browser; its action buttons live
122 // in the top bar's top right.
123 let (selected, set_selected) = signal(Option::<Entry>::None);
124 // Dialogs are rendered here, above everything.
125 let (dialog, set_dialog) = signal(Option::<Dialog>::None);
126 // Long-lived owner for callbacks created inside short-lived scopes.
127 let shell_owner = Owner::current().unwrap();
128 let topbar_ref = NodeRef::<leptos::html::Header>::new();
129
130 // Navigating into a folder invalidates the selection.
131 Effect::new(move |_| {
132 let _ = loc.get();
133 set_selected.set(None);
134 });
135
136 let close_file = Callback::new(move |_| {
137 set_save_cb.set(None);
138 set_close_cb.set(None);
139 set_dirty.set(false);
140 set_saving.set(false);
141 // A folder share returns to the listing; a file share re-opens the
142 // file itself (the editor remounts, so a "discard" is a real one).
143 set_file_view.set(info.get().and_then(|i| file_view_for_share(&i)));
144 });
145
146 // A file share opens straight into its viewer.
147 {
148 let info2 = info;
149 let set_fv = set_file_view;
150 Effect::new(move |_| {
151 if let Some(i) = info2.get()
152 && i.is_file
153 {
154 set_fv.set(file_view_for_share(&i));
155 }
156 });
157 }
158
159 // Escape closes previews and the "no preview" view of a *folder* share.
160 // File shares have nothing to close back to; the editor handles Escape
161 // itself (it asks first when there are unsaved changes).
162 {
163 let fv = file_view;
164 let cf = close_file;
165 let info2 = info;
166 crate::util::owned_window_listener(
167 leptos::ev::keydown,
168 move |ev: web_sys::KeyboardEvent| {
169 if ev.key() != "Escape" {
170 return;
171 }
172 if info2.get().is_some_and(|i| i.is_file) {
173 return;
174 }
175 if fv
176 .get()
177 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
178 {
179 cf.run(());
180 }
181 },
182 );
183 }
184
185 view! {
186 <div
187 class=move || {
188 if file_view.get().is_some() {
189 "layout share-layout file-mode".to_string()
190 } else {
191 "layout share-layout".to_string()
192 }
193 }
194 >
195 <header class="topbar" node_ref=topbar_ref>
196 {move || match file_view.get() {
197 Some(ref v) => view! {
198 // Wrapped like the signed-in app's topbar-left so the
199 // logo + title stay together at the left edge.
200 <div class="topbar-left">
201 // Icon-only brand in file mode, like the
202 // signed-in app: back to `/`.
203 <a class="brand brand-icon" href="#/">
204 <Logo/>
205 </a>
206 <FileViewTitle view=v.clone() dirty=dirty/>
207 </div>
208 }
209 .into_view()
210 .into_any(),
211 None => view! {
212 <a class="brand" href="#/"><Logo/><span class="brand-name">"filebrowser-ng"</span></a>
213 }
214 .into_view()
215 .into_any(),
216 }}
217 <div class="topbar-right">
218 {move || {
219 if let Some(v) = file_view.get() {
220 let show_close = info
221 .get()
222 .map(|i| !i.is_file)
223 .unwrap_or(true);
224 view! {
225 <FileViewActions
226 view=v
227 saving=saving
228 dirty=dirty
229 save_cb=save_cb
230 close_cb=close_cb
231 show_close=show_close
232 on_close=close_file
233 toast=toast
234 />
235 }
236 .into_view()
237 .into_any()
238 } else if state.get() == SharePageState::Active {
239 let refresh = browser_refresh
240 .get()
241 .unwrap_or_else(|| Callback::new(move |_| {}));
242 view! {
243 <SelectionActions
244 me=me
245 loc=loc
246 selected=selected
247 open_file=set_file_view
248 refresh=refresh
249 toast=toast
250 set_dialog=set_dialog
251 owner=shell_owner.clone()
252 topbar_ref=topbar_ref
253 />
254 }
255 .into_view()
256 .into_any()
257 } else {
258 view! {}.into_any()
259 }
260 }}
261 </div>
262 </header>
263 <main class="content" class:file-open=move || file_view.get().is_some()>
264 {move || match state.get() {
265 SharePageState::Loading => view! {
266 <div class="center-screen">
267 <p class="muted">{i18n::tr(i18n::k::LOADING_SHARE)}</p>
268 </div>
269 }
270 .into_view()
271 .into_any(),
272 SharePageState::NotFound => view! {
273 <div class="center-screen">
274 <h2>{i18n::tr(i18n::k::SHARE_NOT_FOUND)}</h2>
275 <p class="muted">{i18n::tr(i18n::k::SHARE_NOT_FOUND_MSG)}</p>
276 </div>
277 }
278 .into_view()
279 .into_any(),
280 SharePageState::Expired => view! {
281 <div class="center-screen">
282 <h2>{i18n::tr(i18n::k::SHARE_EXPIRED)}</h2>
283 <p class="muted">{i18n::tr(i18n::k::SHARE_EXPIRED_MSG)}</p>
284 </div>
285 }
286 .into_view()
287 .into_any(),
288 SharePageState::Active => {
289 let Some(i) = info.get() else {
290 return {
291 view! {}.into_any()
292 };
293 };
294 // File shares render through the full-page file view
295 // (opened by the effect above); only folder shares
296 // get a browser.
297 if i.is_file {
298 view! {}
299 .into_view()
300 .into_any()
301 } else {
302 if me.get().is_none() {
303 return view! {
304 <div class="center-screen">
305 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
306 </div>
307 }
308 .into_view()
309 .into_any();
310 }
311 view! {
312 <Browser
313 me=me
314 set_me=set_me
315 loc=loc
316 open_file=set_file_view
317 set_browser_refresh=set_browser_refresh
318 selected=selected
319 set_selected=set_selected
320 set_dialog=set_dialog
321 dialog=dialog
322 file_view=file_view
323 />
324 }
325 .into_view()
326 .into_any()
327 }
328 }
329 }}
330 {move || match file_view.get() {
331 Some(v) => {
332 let refresh = browser_refresh
333 .get()
334 .unwrap_or_else(|| Callback::new(move |_| {}));
335 view! {
336 <FileViewContent
337 view=v
338 register_save=set_save_cb
339 register_close=set_close_cb
340 dirty=dirty
341 set_dirty=set_dirty
342 set_saving=set_saving
343 close=close_file
344 toast=toast
345 refresh=refresh
346 />
347 }
348 .into_view()
349 .into_any()
350 }
351 None => view! {}.into_any(),
352 }}
353 </main>
354 <ToastView toast=toast/>
355 <DialogView dialog=dialog set_dialog=set_dialog/>
356 </div>
357 }
358}
359