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