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 <FileViewTitle view=v.clone() dirty=dirty/>
199 }
200 .into_view()
201 .into_any(),
202 None => view! {
203 <div class="brand">
204 <Logo/>
205 <span class="brand-name">"filebrowser-ng"</span>
206 </div>
207 }
208 .into_view()
209 .into_any(),
210 }}
211 <div class="topbar-right">
212 {move || {
213 if file_view.get().is_some() {
214 let show_close = info
215 .get()
216 .map(|i| !i.is_file)
217 .unwrap_or(true);
218 view! {
219 <FileViewActions
220 saving=saving
221 dirty=dirty
222 save_cb=save_cb
223 close_cb=close_cb
224 show_close=show_close
225 on_close=close_file
226 />
227 }
228 .into_view()
229 .into_any()
230 } else if state.get() == SharePageState::Active {
231 let refresh = browser_refresh
232 .get()
233 .unwrap_or_else(|| Callback::new(move |_| {}));
234 view! {
235 <SelectionActions
236 me=me
237 loc=loc
238 selected=selected
239 open_file=set_file_view
240 refresh=refresh
241 toast=toast
242 set_dialog=set_dialog
243 owner=shell_owner.clone()
244 topbar_ref=topbar_ref
245 />
246 }
247 .into_view()
248 .into_any()
249 } else {
250 view! {}.into_any()
251 }
252 }}
253 </div>
254 </header>
255 <main class="content" class:file-open=move || file_view.get().is_some()>
256 {move || match state.get() {
257 SharePageState::Loading => view! {
258 <div class="center-screen">
259 <p class="muted">{i18n::tr(i18n::k::LOADING_SHARE)}</p>
260 </div>
261 }
262 .into_view()
263 .into_any(),
264 SharePageState::NotFound => view! {
265 <div class="center-screen">
266 <h2>{i18n::tr(i18n::k::SHARE_NOT_FOUND)}</h2>
267 <p class="muted">{i18n::tr(i18n::k::SHARE_NOT_FOUND_MSG)}</p>
268 </div>
269 }
270 .into_view()
271 .into_any(),
272 SharePageState::Expired => view! {
273 <div class="center-screen">
274 <h2>{i18n::tr(i18n::k::SHARE_EXPIRED)}</h2>
275 <p class="muted">{i18n::tr(i18n::k::SHARE_EXPIRED_MSG)}</p>
276 </div>
277 }
278 .into_view()
279 .into_any(),
280 SharePageState::Active => {
281 let Some(i) = info.get() else {
282 return {
283 view! {}.into_any()
284 };
285 };
286 // File shares render through the full-page file view
287 // (opened by the effect above); only folder shares
288 // get a browser.
289 if i.is_file {
290 view! {}
291 .into_view()
292 .into_any()
293 } else {
294 if me.get().is_none() {
295 return view! {
296 <div class="center-screen">
297 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
298 </div>
299 }
300 .into_view()
301 .into_any();
302 }
303 view! {
304 <Browser
305 me=me
306 set_me=set_me
307 loc=loc
308 open_file=set_file_view
309 set_browser_refresh=set_browser_refresh
310 selected=selected
311 set_selected=set_selected
312 set_dialog=set_dialog
313 file_view=file_view
314 />
315 }
316 .into_view()
317 .into_any()
318 }
319 }
320 }}
321 {move || match file_view.get() {
322 Some(v) => {
323 let refresh = browser_refresh
324 .get()
325 .unwrap_or_else(|| Callback::new(move |_| {}));
326 view! {
327 <FileViewContent
328 view=v
329 register_save=set_save_cb
330 register_close=set_close_cb
331 dirty=dirty
332 set_dirty=set_dirty
333 set_saving=set_saving
334 close=close_file
335 toast=toast
336 refresh=refresh
337 />
338 }
339 .into_view()
340 .into_any()
341 }
342 None => view! {}.into_any(),
343 }}
344 </main>
345 <ToastView toast=toast/>
346 <DialogView dialog=dialog set_dialog=set_dialog/>
347 </div>
348 }
349}
350