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