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