shell.rs
⎇
Raw
1//! The signed-in shell: left sidebar (folders + manage tabs) and the topbar.
2
3use crate::i18n;
4use leptos::prelude::*;
5use wasm_bindgen_futures::spawn_local;
6
7use crate::api::{self, Me};
8use crate::components::icon::Icon;
9use crate::components::toast::{ToastMsg, provide_toast, show_error};
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::search::SearchView;
15use crate::views::shares::SharesView;
16use crate::views::uploads::UploadsView;
17use crate::views::workspace::FileWorkspace;
18
19/// Whether the signed-in user has admin rights.
20fn is_admin_of(me: &Option<Me>) -> bool {
21 me.as_ref()
22 .and_then(|m| m.user.as_ref())
23 .is_some_and(|u| u.is_admin)
24}
25
26/// The root to open on page load, or None to stay where the URL points.
27///
28/// Rules, each one closing a loop or overriding case:
29/// 1. Only once per page load (`applied`), so a later `/me` refresh never
30/// navigates again.
31/// 2. Only from the bare `#/` location. A deep link wins.
32/// 3. Only when the default root is in the root list. The server drops a
33/// stale id already; this is the second guard.
34pub fn pick_default_root(me: &Me, loc: &Location, applied: bool) -> Option<i64> {
35 if applied || *loc != Location::root() {
36 return None;
37 }
38 let id = me.user.as_ref()?.default_root_id?;
39 me.roots.iter().any(|r| r.id == id).then_some(id)
40}
41
42/// Re-fetch this session's `/me`. For things that can change the session
43/// outside of the current view: the sidebar's refresh button, or saving a
44/// user that is the current user.
45pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
46 spawn_local(async move {
47 match api::me().await {
48 Ok(m) => set_me.set(Some(m)),
49 Err(e) => show_error(toast, e.to_string()),
50 }
51 });
52}
53
54#[component]
55pub fn ShellView(
56 me: ReadSignal<Option<Me>>,
57 set_me: WriteSignal<Option<Me>>,
58 set_phase: WriteSignal<crate::app::AuthPhase>,
59 loc: ReadSignal<Location>,
60 theme: ReadSignal<crate::theme::Theme>,
61 set_theme: WriteSignal<crate::theme::Theme>,
62) -> impl IntoView {
63 let toast = provide_toast();
64 // True while the off-canvas sidebar is open (narrow screens only).
65 let (nav_open, set_nav_open) = signal(false);
66 // The shared file-view plumbing (topbar, close/save/dirty, selection,
67 // dialogs, Escape). Closing returns to the browser listing; the
68 // selection actions show wherever the browser is the visible section.
69 let ws = FileWorkspace::new(
70 me,
71 loc,
72 || None,
73 move || loc.get().section == Section::Files,
74 );
75 let file_view = ws.file_view;
76 let open_file = ws.open_file;
77 let selected = ws.selected;
78 let set_selected = ws.set_selected;
79 let dialog = ws.dialog;
80 let set_dialog = ws.set_dialog;
81 let set_browser_refresh = ws.set_browser_refresh;
82 let topbar_ref = ws.topbar_ref;
83 let topbar_left = ws.topbar_left();
84 let topbar_right = ws.topbar_right();
85 let file_content = ws.file_content();
86 let overlays = ws.overlays();
87 // The section and the admin flag change far less often than the
88 // location does. Reading them through memos keeps a folder change from
89 // remounting the whole content component (and the sidebar's root list).
90 let section = Memo::new(move |_| loc.with(|l| l.section));
91 let admin = Memo::new(move |_| is_admin_of(&me.get()));
92 let cur_root = Memo::new(move |_| {
93 me.with(|m| {
94 m.as_ref()
95 .and_then(|m| loc.with(|l| effective_root(&m.roots, l).map(|r| r.id)))
96 })
97 });
98
99 // A non-admin cannot open the admin sections. If the URL points at one
100 // (deep link, hand-typed, or left over from a demotion), redirect to the
101 // files view so the URL, the content fallback, and the sidebar highlight
102 // all agree.
103 {
104 let me2 = me;
105 let loc2 = loc;
106 Effect::new(move |_| {
107 let Some(m) = me2.get() else {
108 return;
109 };
110 let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
111 if admin {
112 return;
113 }
114 let section = loc2.get().section;
115 if section == Section::Users {
116 navigate(&Location::root());
117 }
118 });
119 }
120
121 // Open the profile's default root once per page load. The guards keep
122 // this from looping: a stale default that 404s refreshes `/me`, which
123 // must not trigger a second redirect.
124 {
125 let applied = StoredValue::new(false);
126 Effect::new(move |_| {
127 let Some(m) = me.get() else {
128 return;
129 };
130 let target = loc.with_untracked(|l| pick_default_root(&m, l, applied.get_value()));
131 applied.set_value(true);
132 if let Some(id) = target {
133 navigate(&Location {
134 root_id: Some(id),
135 ..Location::root()
136 });
137 }
138 });
139 }
140
141 let on_logout = move |_| {
142 spawn_local(async move {
143 let _ = api::logout().await;
144 set_me.set(None);
145 set_phase.set(crate::app::AuthPhase::Login);
146 });
147 };
148
149 // Navigate to a manage section (each has its own URL) and, on narrow
150 // screens, close the off-canvas sidebar.
151 let open_section = move |section: Section| {
152 set_nav_open.set(false);
153 navigate(&Location {
154 root_id: None,
155 path: Vec::new(),
156 share_token: None,
157 section,
158 });
159 };
160
161 view! {
162 <div
163 class=move || {
164 if file_view.get().is_some() {
165 "layout file-mode".to_string()
166 } else {
167 "layout".to_string()
168 }
169 }
170 >
171 <header class="topbar" node_ref=topbar_ref>
172 <div class="topbar-left">
173 <button
174 class="icon-btn nav-toggle"
175 title={i18n::tr(i18n::k::MENU)}
176 aria-label={i18n::tr(i18n::k::OPEN_MENU)}
177 on:click=move |_| set_nav_open.update(|v| *v = !*v)
178 >
179 <Icon name=IconName::Menu class="ic-btn".to_string()/>
180 </button>
181 {topbar_left}
182 </div>
183 <div class="topbar-right">
184 {topbar_right}
185 </div>
186 </header>
187 {move || {
188 if nav_open.get() {
189 view! {
190 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
191 }
192 .into_view()
193 .into_any()
194 } else {
195 view! {}.into_any()
196 }
197 }}
198 <div class="body-row">
199 {move || {
200 if file_view.get().is_none() {
201 view! {
202 <aside
203 class="sidebar"
204 class:open=move || nav_open.get()
205 >
206 <nav class="nav">
207 {move || {
208 let Some(u) = me.get().and_then(|m| m.user) else {
209 return {
210 view! {}.into_any()
211 };
212 };
213 view! {
214 <div class="sidebar-user">{u.name.clone()}</div>
215 }
216 .into_view()
217 .into_any()
218 }}
219 <div class="nav-section">
220 <div class="nav-section-head">
221 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
222 <button
223 class="icon-btn icon-btn-sm nav-section-refresh"
224 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
225 on:click=move |_| refresh_me(set_me, toast)
226 >
227 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
228 </button>
229 </div>
230 <button
231 class="nav-tab"
232 class:active=move || section.get() == Section::Search
233 on:click=move |_| open_section(Section::Search)
234 >
235 <Icon name=IconName::Search class="ic-tab".to_string()/>
236 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
237 </button>
238 {move || {
239 let Some(m) = me.get() else {
240 return {
241 view! {}.into_any()
242 };
243 };
244 m.roots
245 .iter()
246 .map(|r| {
247 let id = r.id;
248 let name = r.name.clone();
249 // The root's path never changes while
250 // the button exists, so the tooltip is
251 // not a reactive closure.
252 let title = if r.path == "." {
253 "/".to_string()
254 } else {
255 r.path.clone()
256 };
257 let ro = !r.mode.is_writable();
258 view! {
259 <button
260 class="nav-tab"
261 class:active=move || {
262 section.get() == Section::Files
263 && cur_root.get() == Some(id)
264 }
265 title=title
266 on:click=move |_| {
267 set_nav_open.set(false);
268 navigate(&Location {
269 root_id: Some(id),
270 path: vec![],
271 share_token: None,
272 section: Section::Files,
273 });
274 }
275 >
276 <Icon name=IconName::Folder class="ic-tab".to_string()/>
277 <span class="nav-tab-name">{name}</span>
278 {move || {
279 if ro {
280 view! { <span class="nav-tab-note">"ro"</span> }
281 .into_view()
282 .into_any()
283 } else {
284 view! {}.into_any()
285 }
286 }}
287 </button>
288 }
289 })
290 .collect::<Vec<_>>()
291 .into_view()
292 .into_any()
293 }}
294 </div>
295 <div class="nav-section">
296 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
297 <button
298 class="nav-tab"
299 class:active=move || section.get() == Section::Shares
300 on:click=move |_| open_section(Section::Shares)
301 >
302 <Icon name=IconName::Share class="ic-tab".to_string()/>
303 <span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
304 </button>
305 <Show when=move || admin.get()>
306 <button
307 class="nav-tab"
308 class:active=move || section.get() == Section::Users
309 on:click=move |_| open_section(Section::Users)
310 >
311 <Icon name=IconName::User class="ic-tab".to_string()/>
312 <span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
313 </button>
314 </Show>
315 <button
316 class="nav-tab"
317 class:active=move || section.get() == Section::Uploads
318 on:click=move |_| open_section(Section::Uploads)
319 >
320 <Icon name=IconName::Upload class="ic-tab".to_string()/>
321 <span class="nav-tab-name">{i18n::tr(i18n::k::UPLOADS)}</span>
322 </button>
323 <button
324 class="nav-tab"
325 class:active=move || section.get() == Section::Settings
326 on:click=move |_| open_section(Section::Settings)
327 >
328 <Icon name=IconName::Settings class="ic-tab".to_string()/>
329 <span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
330 </button>
331 </div>
332 </nav>
333 <div class="sidebar-footer">
334 <button
335 class="nav-tab sidebar-action"
336 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
337 on:click=move |_| {
338 let next = theme.get().next();
339 set_theme.set(next);
340 next.store();
341 next.apply();
342 }
343 >
344 {move || {
345 view! {
346 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
347 }
348 }}
349 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
350 </button>
351 <button
352 class="nav-tab sidebar-action logout"
353 title={i18n::tr(i18n::k::LOG_OUT)}
354 on:click=move |_| on_logout(())
355 >
356 <Icon name=IconName::Logout class="ic-tab".to_string()/>
357 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
358 </button>
359 <div class="sidebar-version">
360 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
361 </div>
362 </div>
363 </aside>
364 }
365 .into_view()
366 .into_any()
367 } else {
368 view! {}.into_any()
369 }
370 }}
371 <main class="content" class:file-open=move || file_view.get().is_some()>
372 {move || {
373 match section.get() {
374 Section::Files => view! {
375 <Browser
376 me=me
377 set_me=set_me
378 loc=loc
379 open_file=open_file
380 set_browser_refresh=set_browser_refresh
381 selected=selected
382 set_selected=set_selected
383 set_dialog=set_dialog
384 dialog=dialog
385 file_view=file_view
386 />
387 }
388 .into_view()
389 .into_any(),
390 Section::Search => view! {
391 <SearchView
392 me=me
393 open_file=open_file
394 file_view=file_view
395 set_dialog=set_dialog
396 />
397 }
398 .into_view()
399 .into_any(),
400 Section::Shares => view! { <SharesView me=me/> }
401 .into_view()
402 .into_any(),
403 Section::Users if admin.get() => {
404 view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
405 }
406 Section::Settings => view! {
407 <SettingsView me=me set_me=set_me set_dialog=set_dialog/>
408 }
409 .into_view()
410 .into_any(),
411 Section::Uploads => view! { <UploadsView/> }.into_view().into_any(),
412 // Not an admin anymore (e.g. permissions changed):
413 // fall back to the files view.
414 _ => view! {
415 <Browser
416 me=me
417 set_me=set_me
418 loc=loc
419 open_file=open_file
420 set_browser_refresh=set_browser_refresh
421 selected=selected
422 set_selected=set_selected
423 set_dialog=set_dialog
424 dialog=dialog
425 file_view=file_view
426 />
427 }
428 .into_view()
429 .into_any(),
430 }
431 }}
432 {file_content}
433 </main>
434 </div>
435 {overlays}
436 </div>
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::api::{Mode, RootInfo, UserInfo};
444
445 fn me(default: Option<i64>) -> Me {
446 Me {
447 first_boot: false,
448 user: Some(UserInfo {
449 id: 1,
450 name: "u".into(),
451 is_admin: false,
452 single_click_open: false,
453 thumbnails: false,
454 language: None,
455 default_root_id: default,
456 auth_mode: Default::default(),
457 has_password: true,
458 }),
459 roots: vec![RootInfo {
460 id: 7,
461 name: "r".into(),
462 path: "r".into(),
463 mode: Mode::Rw,
464 }],
465 allow_writable_shares: false,
466 thumbnails_available: false,
467 public_url: None,
468 }
469 }
470
471 #[test]
472 fn default_root_rules() {
473 let home = Location::root();
474 assert_eq!(pick_default_root(&me(Some(7)), &home, false), Some(7));
475 // Once per page load.
476 assert_eq!(pick_default_root(&me(Some(7)), &home, true), None);
477 // A deep link wins.
478 let deep = Location {
479 root_id: Some(7),
480 ..Location::root()
481 };
482 assert_eq!(pick_default_root(&me(Some(7)), &deep, false), None);
483 let section = Location {
484 section: Section::Shares,
485 ..Location::root()
486 };
487 assert_eq!(pick_default_root(&me(Some(7)), &section, false), None);
488 // Unknown root, or none set.
489 assert_eq!(pick_default_root(&me(Some(8)), &home, false), None);
490 assert_eq!(pick_default_root(&me(None), &home, false), None);
491 }
492}
493