share_page.rs
⎇
Raw
1//! Public share page (milestone 6): renders a shared item without login.
2
3use leptos::prelude::*;
4use wasm_bindgen_futures::spawn_local;
5
6use crate::api::{self, Me, Mode, RootInfo, ShareInfo, UserInfo};
7use crate::components::icon::Icon;
8use crate::components::logo::Logo;
9use crate::components::toast::{ToastView, provide_toast};
10use crate::icons::IconName;
11use crate::router::Location;
12use crate::views::browser::Browser;
13use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
14
15#[derive(Clone, PartialEq)]
16enum SharePageState {
17 Loading,
18 NotFound,
19 Expired,
20 Active,
21}
22
23#[component]
24pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
25 let toast = provide_toast();
26 let (me, set_me) = signal(Option::<Me>::None);
27 let (info, set_info) = signal(Option::<ShareInfo>::None);
28 let (state, set_state) = signal(SharePageState::Loading);
29
30 // Resolve the share once per token and scope all file API calls to it.
31 {
32 let tok = token.clone();
33 let set_me2 = set_me;
34 let set_info2 = set_info;
35 let set_state2 = set_state;
36 spawn_local(async move {
37 api::set_share_token(Some(&tok));
38 match api::resolve_share(&tok).await {
39 Ok(i) => {
40 let mode = if i.writable { Mode::Rw } else { Mode::Ro };
41 let me_val = Me {
42 first_boot: false,
43 user: Some(UserInfo {
44 id: i.id,
45 name: "shared".to_string(),
46 is_admin: false,
47 }),
48 roots: vec![RootInfo {
49 id: i.root_id,
50 name: i.name.clone(),
51 path: "/".to_string(),
52 mode,
53 }],
54 allow_writable_shares: false,
55 };
56 set_me2.set(Some(me_val));
57 set_info2.set(Some(i));
58 set_state2.set(SharePageState::Active);
59 }
60 Err(e) => {
61 let expired = e.status() == Some(410);
62 set_state2.set(if expired {
63 SharePageState::Expired
64 } else {
65 SharePageState::NotFound
66 });
67 }
68 }
69 });
70 on_cleanup(move || api::set_share_token(None));
71 }
72
73 // A file opened in the full-page view; the top bar doubles as its header.
74 let (file_view, set_file_view) = signal(Option::<FileView>::None);
75 // The open editor stores its save callback here for the top-bar button.
76 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
77 // The open editor's guarded close request (unsaved-changes prompt).
78 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
79 let (dirty, set_dirty) = signal(false);
80 let (saving, set_saving) = signal(false);
81 // The browser's fetch, exposed so the editor can re-list after a save.
82 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
83
84 let close_file = Callback::new(move |_| {
85 set_file_view.set(None);
86 set_save_cb.set(None);
87 set_close_cb.set(None);
88 set_dirty.set(false);
89 set_saving.set(false);
90 });
91
92 // Escape closes previews and the "no preview" view. The editor handles
93 // Escape itself (it asks first when there are unsaved changes).
94 {
95 let fv = file_view;
96 let cf = close_file;
97 crate::util::owned_window_listener(
98 leptos::ev::keydown,
99 move |ev: web_sys::KeyboardEvent| {
100 if ev.key() != "Escape" {
101 return;
102 }
103 if fv
104 .get()
105 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
106 {
107 cf.run(());
108 }
109 },
110 );
111 }
112
113 view! {
114 <div
115 class=move || {
116 if file_view.get().is_some() {
117 "layout share-layout file-mode".to_string()
118 } else {
119 "layout share-layout".to_string()
120 }
121 }
122 >
123 <header class="topbar">
124 {move || match file_view.get() {
125 Some(ref v) => view! {
126 <FileViewTitle view=v.clone() dirty=dirty/>
127 }
128 .into_view()
129 .into_any(),
130 None => view! {
131 <div class="brand">
132 <Logo/>
133 <span class="brand-name">"filebrowser-ng"</span>
134 </div>
135 }
136 .into_view()
137 .into_any(),
138 }}
139 <div class="topbar-right">
140 {move || match file_view.get() {
141 Some(_) => view! {
142 <FileViewActions
143 saving=saving
144 dirty=dirty
145 save_cb=save_cb
146 close_cb=close_cb
147 on_close=close_file
148 />
149 }
150 .into_view()
151 .into_any(),
152 None => {
153 let name = info.get().map(|i| i.name.clone());
154 match name {
155 Some(n) => view! {
156 <span class="share-badge">
157 <Icon name=IconName::Share class="ic-badge".to_string()/>
158 {format!("Shared: {n}")}
159 </span>
160 }
161 .into_view()
162 .into_any(),
163 None => {
164 view! {}.into_any()
165 },
166 }
167 }
168 }}
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">"Loading share…"</p>
176 </div>
177 }
178 .into_view()
179 .into_any(),
180 SharePageState::NotFound => view! {
181 <div class="center-screen">
182 <h2>"Share not found"</h2>
183 <p class="muted">"This link is invalid or the share was removed."</p>
184 </div>
185 }
186 .into_view()
187 .into_any(),
188 SharePageState::Expired => view! {
189 <div class="center-screen">
190 <h2>"Share expired"</h2>
191 <p class="muted">"This share link is no longer active."</p>
192 </div>
193 }
194 .into_view()
195 .into_any(),
196 SharePageState::Active => {
197 let Some(i) = info.get() else {
198 return {
199 view! {}.into_any()
200 };
201 };
202 if i.is_file {
203 let name = i.name.clone();
204 let rid = i.root_id;
205 view! {
206 <div class="share-file-page">
207 <Icon name=IconName::File class="share-file-glyph".to_string()/>
208 <h2>{name.clone()}</h2>
209 <div class="share-file-actions">
210 <button
211 class="btn btn-primary"
212 on:click=move |_| {
213 let url = api::download_url(rid, "", None);
214 api::trigger_download(&url, &name);
215 }
216 >
217 "Download"
218 </button>
219 </div>
220 </div>
221 }
222 .into_view()
223 .into_any()
224 } else {
225 if me.get().is_none() {
226 return view! {
227 <div class="center-screen">
228 <p class="muted">"Loading…"</p>
229 </div>
230 }
231 .into_view()
232 .into_any();
233 }
234 view! {
235 <Browser
236 me=me
237 set_me=set_me
238 loc=loc
239 open_file=set_file_view
240 set_browser_refresh=set_browser_refresh
241 />
242 }
243 .into_view()
244 .into_any()
245 }
246 }
247 }}
248 {move || match file_view.get() {
249 Some(v) => {
250 let refresh = browser_refresh
251 .get()
252 .unwrap_or_else(|| Callback::new(move |_| {}));
253 view! {
254 <FileViewContent
255 view=v
256 register_save=set_save_cb
257 register_close=set_close_cb
258 dirty=dirty
259 set_dirty=set_dirty
260 set_saving=set_saving
261 close=close_file
262 toast=toast
263 refresh=refresh
264 />
265 }
266 .into_view()
267 .into_any()
268 }
269 None => view! {}.into_any(),
270 }}
271 </main>
272 <footer class="share-footer muted">"Powered by filebrowser-ng"</footer>
273 <ToastView toast=toast/>
274 </div>
275 }
276}
277