shell.rs
⎇
Raw
1//! The signed-in shell: left sidebar (folders + manage tabs) and the topbar.
2
3use leptos::prelude::*;
4use wasm_bindgen_futures::spawn_local;
5
6use crate::api::{self, Me};
7use crate::components::icon::Icon;
8use crate::components::logo::Logo;
9use crate::components::toast::{ToastMsg, ToastView, provide_toast, show};
10use crate::icons::IconName;
11use crate::router::{Location, Section, navigate};
12use crate::views::admin::{SettingsView, UsersView};
13use crate::views::browser::{Browser, effective_root};
14use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
15use crate::views::shares::SharesView;
16
17/// Whether the signed-in user has admin rights.
18fn is_admin_of(me: &Option<Me>) -> bool {
19 me.as_ref()
20 .and_then(|m| m.user.as_ref())
21 .is_some_and(|u| u.is_admin)
22}
23
24/// Re-fetch this session's `/me`. For things that can change the session
25/// outside of the current view: the sidebar's refresh button, or saving a
26/// user that is the current user.
27pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
28 spawn_local(async move {
29 match api::me().await {
30 Ok(m) => set_me.set(Some(m)),
31 Err(e) => show(toast, e.to_string()),
32 }
33 });
34}
35
36#[component]
37pub fn ShellView(
38 me: ReadSignal<Option<Me>>,
39 set_me: WriteSignal<Option<Me>>,
40 set_phase: WriteSignal<crate::app::AuthPhase>,
41 loc: ReadSignal<Location>,
42 theme: ReadSignal<crate::theme::Theme>,
43 set_theme: WriteSignal<crate::theme::Theme>,
44) -> impl IntoView {
45 let toast = provide_toast();
46 // True while the off-canvas sidebar is open (narrow screens only).
47 let (nav_open, set_nav_open) = signal(false);
48 // A file opened in the full-page view (preview / editor / "no preview");
49 // the top bar doubles as its header.
50 let (file_view, set_file_view) = signal(Option::<FileView>::None);
51 // The open editor stores its save callback here for the top-bar button.
52 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
53 // The open editor's guarded close request (unsaved-changes prompt).
54 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
55 // The editor's state, shared with the top bar (dirty dot, Save button).
56 let (dirty, set_dirty) = signal(false);
57 let (saving, set_saving) = signal(false);
58 // The browser's fetch, exposed so the editor can re-list after a save.
59 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
60
61 let close_file = Callback::new(move |_| {
62 set_file_view.set(None);
63 set_save_cb.set(None);
64 set_close_cb.set(None);
65 set_dirty.set(false);
66 set_saving.set(false);
67 });
68
69 // Escape closes previews and the "no preview" view. The editor handles
70 // Escape itself (it asks first when there are unsaved changes).
71 {
72 let fv = file_view;
73 let cf = close_file;
74 crate::util::owned_window_listener(
75 leptos::ev::keydown,
76 move |ev: web_sys::KeyboardEvent| {
77 if ev.key() != "Escape" {
78 return;
79 }
80 if fv
81 .get()
82 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
83 {
84 cf.run(());
85 }
86 },
87 );
88 }
89
90 // A non-admin cannot open the admin sections. If the URL points at one
91 // (deep link, hand-typed, or left over from a demotion), redirect to the
92 // files view so the URL, the content fallback, and the sidebar highlight
93 // all agree.
94 {
95 let me2 = me;
96 let loc2 = loc;
97 Effect::new(move |_| {
98 let Some(m) = me2.get() else {
99 return;
100 };
101 let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
102 if admin {
103 return;
104 }
105 let section = loc2.get().section;
106 if section == Section::Users || section == Section::Settings {
107 navigate(&Location::root());
108 }
109 });
110 }
111
112 let on_logout = move |_| {
113 spawn_local(async move {
114 let _ = api::logout().await;
115 set_me.set(None);
116 set_phase.set(crate::app::AuthPhase::Login);
117 });
118 };
119
120 // Navigate to a manage section (each has its own URL) and, on narrow
121 // screens, close the off-canvas sidebar.
122 let open_section = move |section: Section| {
123 set_nav_open.set(false);
124 navigate(&Location {
125 root_id: None,
126 path: Vec::new(),
127 share_token: None,
128 section,
129 });
130 };
131
132 view! {
133 <div
134 class=move || {
135 if file_view.get().is_some() {
136 "layout file-mode".to_string()
137 } else {
138 "layout".to_string()
139 }
140 }
141 >
142 <header class="topbar">
143 <div class="topbar-left">
144 <button
145 class="icon-btn nav-toggle"
146 title="Menu"
147 aria-label="Open menu"
148 on:click=move |_| set_nav_open.update(|v| *v = !*v)
149 >
150 <Icon name=IconName::Menu class="ic-btn".to_string()/>
151 </button>
152 {move || match file_view.get() {
153 Some(ref v) => view! {
154 <FileViewTitle view=v.clone() dirty=dirty/>
155 }
156 .into_view()
157 .into_any(),
158 None => view! {
159 <div class="brand">
160 <Logo/>
161 <span class="brand-name">"filebrowser-ng"</span>
162 </div>
163 }
164 .into_view()
165 .into_any(),
166 }}
167 </div>
168 {move || {
169 if file_view.get().is_some() {
170 view! {
171 <FileViewActions
172 saving=saving
173 dirty=dirty
174 save_cb=save_cb
175 close_cb=close_cb
176 on_close=close_file
177 />
178 }
179 .into_view()
180 .into_any()
181 } else {
182 view! {}.into_any()
183 }
184 }}
185 </header>
186 {move || {
187 if nav_open.get() {
188 view! {
189 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
190 }
191 .into_view()
192 .into_any()
193 } else {
194 view! {}.into_any()
195 }
196 }}
197 <div class="body-row">
198 {move || {
199 if file_view.get().is_none() {
200 view! {
201 <aside
202 class=move || {
203 if nav_open.get() {
204 "sidebar open".to_string()
205 } else {
206 "sidebar".to_string()
207 }
208 }
209 >
210 <nav class="nav">
211 {move || {
212 let Some(u) = me.get().and_then(|m| m.user) else {
213 return {
214 view! {}.into_any()
215 };
216 };
217 view! {
218 <div class="sidebar-user">{u.name.clone()}</div>
219 }
220 .into_view()
221 .into_any()
222 }}
223 <div class="nav-section">
224 <div class="nav-section-head">
225 <div class="nav-section-label">"Files"</div>
226 <button
227 class="icon-btn icon-btn-sm nav-section-refresh"
228 title="Refresh your folders"
229 on:click=move |_| refresh_me(set_me, toast)
230 >
231 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
232 </button>
233 </div>
234 {move || {
235 let Some(m) = me.get() else {
236 return {
237 view! {}.into_any()
238 };
239 };
240 let cur_root = effective_root(&m.roots, &loc.get()).map(|r| r.id);
241 m.roots
242 .iter()
243 .map(|r| {
244 let id = r.id;
245 let name = r.name.clone();
246 let path = r.path.clone();
247 let ro = !r.mode.is_writable();
248 view! {
249 <button
250 class="nav-tab"
251 class:active=move || {
252 loc.get().section == Section::Files
253 && cur_root == Some(id)
254 }
255 title=move || {
256 if path == "." {
257 "/".to_string()
258 } else {
259 path.clone()
260 }
261 }
262 on:click=move |_| {
263 set_nav_open.set(false);
264 navigate(&Location {
265 root_id: Some(id),
266 path: vec![],
267 share_token: None,
268 section: Section::Files,
269 });
270 }
271 >
272 <Icon name=IconName::Folder class="ic-tab".to_string()/>
273 <span class="nav-tab-name">{name}</span>
274 {move || {
275 if ro {
276 view! { <span class="nav-tab-note">"ro"</span> }
277 .into_view()
278 .into_any()
279 } else {
280 view! {}.into_any()
281 }
282 }}
283 </button>
284 }
285 })
286 .collect::<Vec<_>>()
287 .into_view()
288 .into_any()
289 }}
290 </div>
291 <div class="nav-section">
292 <div class="nav-section-label">"Manage"</div>
293 <button
294 class="nav-tab"
295 class:active=move || loc.get().section == Section::Shares
296 on:click=move |_| open_section(Section::Shares)
297 >
298 <Icon name=IconName::Share class="ic-tab".to_string()/>
299 <span class="nav-tab-name">"Shares"</span>
300 </button>
301 <Show when=move || is_admin_of(&me.get())>
302 <button
303 class="nav-tab"
304 class:active=move || loc.get().section == Section::Users
305 on:click=move |_| open_section(Section::Users)
306 >
307 <Icon name=IconName::User class="ic-tab".to_string()/>
308 <span class="nav-tab-name">"Users"</span>
309 </button>
310 <button
311 class="nav-tab"
312 class:active=move || loc.get().section == Section::Settings
313 on:click=move |_| open_section(Section::Settings)
314 >
315 <Icon name=IconName::Settings class="ic-tab".to_string()/>
316 <span class="nav-tab-name">"Settings"</span>
317 </button>
318 </Show>
319 </div>
320 </nav>
321 <div class="sidebar-footer">
322 <button
323 class="nav-tab sidebar-action"
324 title=move || format!("Theme: {} (click to switch)", theme.get().label())
325 on:click=move |_| {
326 let next = theme.get().next();
327 set_theme.set(next);
328 next.store();
329 next.apply();
330 }
331 >
332 {move || {
333 view! {
334 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
335 }
336 }}
337 <span class="nav-tab-name">{move || theme.get().name().to_string()}</span>
338 </button>
339 <button
340 class="nav-tab sidebar-action logout"
341 title="Log out"
342 on:click=move |_| on_logout(())
343 >
344 <Icon name=IconName::Logout class="ic-tab".to_string()/>
345 <span class="nav-tab-name">"Log out"</span>
346 </button>
347 <div class="sidebar-version">
348 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
349 </div>
350 </div>
351 </aside>
352 }
353 .into_view()
354 .into_any()
355 } else {
356 view! {}.into_any()
357 }
358 }}
359 <main class="content" class:file-open=move || file_view.get().is_some()>
360 {move || {
361 let admin = is_admin_of(&me.get());
362 match loc.get().section {
363 Section::Files => view! {
364 <Browser
365 me=me
366 set_me=set_me
367 loc=loc
368 open_file=set_file_view
369 set_browser_refresh=set_browser_refresh
370 />
371 }
372 .into_view()
373 .into_any(),
374 Section::Shares => view! { <SharesView/> }
375 .into_view()
376 .into_any(),
377 Section::Users if admin => {
378 view! { <UsersView me=me set_me=set_me/> }.into_view().into_any()
379 }
380 Section::Settings if admin => {
381 view! { <SettingsView set_me=set_me/> }.into_view().into_any()
382 }
383 // Not an admin anymore (e.g. permissions changed):
384 // fall back to the files view.
385 _ => view! {
386 <Browser
387 me=me
388 set_me=set_me
389 loc=loc
390 open_file=set_file_view
391 set_browser_refresh=set_browser_refresh
392 />
393 }
394 .into_view()
395 .into_any(),
396 }
397 }}
398 {move || match file_view.get() {
399 Some(v) => {
400 let refresh = browser_refresh
401 .get()
402 .unwrap_or_else(|| Callback::new(move |_| {}));
403 view! {
404 <FileViewContent
405 view=v
406 register_save=set_save_cb
407 register_close=set_close_cb
408 dirty=dirty
409 set_dirty=set_dirty
410 set_saving=set_saving
411 close=close_file
412 toast=toast
413 refresh=refresh
414 />
415 }
416 .into_view()
417 .into_any()
418 }
419 None => view! {}.into_any(),
420 }}
421 </main>
422 </div>
423 <ToastView toast=toast/>
424 </div>
425 }
426}
427