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