share_page.rs
⎇
Raw
1//! Public share page: 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, Me, Mode, RootInfo, ShareInfo, UserInfo};
9use crate::components::toast::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::{FileView, UnsupportedTarget};
15use crate::views::workspace::FileWorkspace;
16
17#[derive(Clone, PartialEq)]
18enum SharePageState {
19 Loading,
20 NotFound,
21 Expired,
22 /// The share asks for a password. The server withholds even its name
23 /// until the password is entered, so nothing about it is known yet.
24 Locked,
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 _ = 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 // Apply a resolved share: it becomes the synthetic "me" every file call
67 // is scoped to. Shared by the initial resolve and by the unlock form.
68 let accept = Callback::new(move |i: ShareInfo| {
69 let mode = if i.writable { Mode::Rw } else { Mode::Ro };
70 set_me.set(Some(Me {
71 first_boot: false,
72 user: Some(UserInfo {
73 id: i.id,
74 name: "shared".to_string(),
75 is_admin: false,
76 single_click_open: false,
77 thumbnails: true,
78 language: None,
79 default_root_id: None,
80 }),
81 roots: vec![RootInfo {
82 id: i.root_id,
83 name: i.name.clone(),
84 path: "/".to_string(),
85 mode,
86 }],
87 allow_writable_shares: false,
88 // A share page cannot read the server config, so it asks and lets
89 // the 404 decide. The tile falls back to its icon either way.
90 thumbnails_available: true,
91 public_url: None,
92 }));
93 set_info.set(Some(i));
94 set_state.set(SharePageState::Active);
95 });
96
97 // Resolve the share once per token and scope all file API calls to it.
98 {
99 let tok = token.clone();
100 let set_state2 = set_state;
101 spawn_local(async move {
102 api::set_share_token(Some(&tok));
103 match api::resolve_share(&tok).await {
104 Ok(i) => accept.run(i),
105 // 401 means password protected, not missing or gone.
106 Err(e) => set_state2.set(match e.status() {
107 Some(410) => SharePageState::Expired,
108 Some(401) => SharePageState::Locked,
109 _ => SharePageState::NotFound,
110 }),
111 }
112 });
113 on_cleanup(move || api::set_share_token(None));
114 }
115
116 // The shared file-view plumbing (topbar, close/save/dirty, selection,
117 // dialogs, Escape). Closing a folder share returns to the listing;
118 // closing a file share re-opens the file itself (there is nothing to
119 // close back to, so the close button and Escape stay disabled as
120 // well).
121 let token_for_form = token.clone();
122
123 let ws = FileWorkspace::new(
124 me,
125 loc,
126 move || info.get().and_then(|i| file_view_for_share(&i)),
127 move || state.get() == SharePageState::Active,
128 );
129 let file_view = ws.file_view;
130 let open_file = ws.open_file;
131 let selected = ws.selected;
132 let set_selected = ws.set_selected;
133 let dialog = ws.dialog;
134 let set_dialog = ws.set_dialog;
135 let set_browser_refresh = ws.set_browser_refresh;
136 let topbar_ref = ws.topbar_ref;
137 let topbar_left = ws.topbar_left();
138 let topbar_right = ws.topbar_right();
139 let file_content = ws.file_content();
140 let overlays = ws.overlays();
141
142 // A file share opens straight into its viewer.
143 {
144 let info2 = info;
145 let set_fv = open_file;
146 Effect::new(move |_| {
147 if let Some(i) = info2.get()
148 && i.is_file
149 {
150 set_fv.set(file_view_for_share(&i));
151 }
152 });
153 }
154
155 view! {
156 <div
157 class=move || {
158 if file_view.get().is_some() {
159 "layout share-layout file-mode".to_string()
160 } else {
161 "layout share-layout".to_string()
162 }
163 }
164 >
165 <header class="topbar" node_ref=topbar_ref>
166 <div class="topbar-left">
167 {topbar_left}
168 </div>
169 <div class="topbar-right">
170 {topbar_right}
171 </div>
172 </header>
173 <main class="content" class:file-open=move || file_view.get().is_some()>
174 {move || match state.get() {
175 SharePageState::Loading => view! {
176 <div class="center-screen">
177 <p class="muted">{i18n::tr(i18n::k::LOADING_SHARE)}</p>
178 </div>
179 }
180 .into_view()
181 .into_any(),
182 SharePageState::NotFound => view! {
183 <div class="center-screen">
184 <h2>{i18n::tr(i18n::k::SHARE_NOT_FOUND)}</h2>
185 <p class="muted">{i18n::tr(i18n::k::SHARE_NOT_FOUND_MSG)}</p>
186 </div>
187 }
188 .into_view()
189 .into_any(),
190 SharePageState::Expired => view! {
191 <div class="center-screen">
192 <h2>{i18n::tr(i18n::k::SHARE_EXPIRED)}</h2>
193 <p class="muted">{i18n::tr(i18n::k::SHARE_EXPIRED_MSG)}</p>
194 </div>
195 }
196 .into_view()
197 .into_any(),
198 SharePageState::Locked => view! {
199 <UnlockForm token=token_for_form.clone() accept=accept/>
200 }
201 .into_view()
202 .into_any(),
203 SharePageState::Active => {
204 let Some(i) = info.get() else {
205 return {
206 view! {}.into_any()
207 };
208 };
209 // File shares render through the full-page file view
210 // (opened by the effect above); only folder shares
211 // get a browser.
212 if i.is_file {
213 view! {}
214 .into_view()
215 .into_any()
216 } else {
217 if me.get().is_none() {
218 return view! {
219 <div class="center-screen">
220 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
221 </div>
222 }
223 .into_view()
224 .into_any();
225 }
226 view! {
227 <Browser
228 me=me
229 set_me=set_me
230 loc=loc
231 open_file=open_file
232 set_browser_refresh=set_browser_refresh
233 selected=selected
234 set_selected=set_selected
235 set_dialog=set_dialog
236 dialog=dialog
237 file_view=file_view
238 />
239 }
240 .into_view()
241 .into_any()
242 }
243 }
244 }}
245 {file_content}
246 </main>
247 {overlays}
248 </div>
249 }
250}
251
252/// The password gate of a protected share. The page knows nothing about the
253/// share yet, so it shows the prompt alone.
254#[component]
255fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
256 let (password, set_password) = signal(String::new());
257 let (error, set_error) = signal(Option::<String>::None);
258 let (busy, set_busy) = signal(false);
259
260 // A Callback, not a plain closure: both the button and Enter need it,
261 // and a closure would be moved into whichever handler came first.
262 let submit = Callback::new(move |_: ()| {
263 let pw = password.get();
264 if pw.is_empty() || busy.get() {
265 return;
266 }
267 set_busy.set(true);
268 set_error.set(None);
269 let tok = token.clone();
270 spawn_local(async move {
271 match api::unlock_share(&tok, &pw).await {
272 Ok(i) => accept.run(i),
273 Err(e) => {
274 set_error.set(Some(e.to_string()));
275 set_busy.set(false);
276 }
277 }
278 });
279 });
280
281 view! {
282 <div class="center-screen">
283 <div class="card unlock-card">
284 <h2>{i18n::tr(i18n::k::SHARE_LOCKED_TITLE)}</h2>
285 <p class="muted">{i18n::tr(i18n::k::ERR_SHARE_LOCKED)}</p>
286 <input
287 type="password"
288 autocomplete="current-password"
289 autofocus=true
290 value=move || password.get()
291 on:input=move |ev: web_sys::Event| {
292 use wasm_bindgen::JsCast;
293 if let Some(t) = ev
294 .target()
295 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
296 {
297 set_password.set(t.value());
298 set_error.set(None);
299 }
300 }
301 on:keydown=move |ev: web_sys::KeyboardEvent| {
302 if ev.key() == "Enter" {
303 ev.prevent_default();
304 submit.run(());
305 }
306 }
307 />
308 {move || {
309 let Some(e) = error.get() else {
310 return view! {}.into_any();
311 };
312 view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
313 }}
314 <button
315 class="btn btn-primary unlock-btn"
316 disabled=move || busy.get() || password.get().is_empty()
317 on:click=move |_| submit.run(())
318 >
319 {i18n::tr(i18n::k::UNLOCK)}
320 </button>
321 </div>
322 </div>
323 }
324}
325