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