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::folder(Some(id), None, vec![]));
134 }
135 });
136 }
137
138 let on_logout = move |_| {
139 spawn_local(async move {
140 let _ = api::logout().await;
141 set_me.set(None);
142 set_phase.set(crate::app::AuthPhase::Login);
143 });
144 };
145
146 // Navigate to a manage section (each has its own URL) and, on narrow
147 // screens, close the off-canvas sidebar.
148 let open_section = move |section: Section| {
149 set_nav_open.set(false);
150 navigate(&Location {
151 section,
152 ..Location::root()
153 });
154 };
155
156 view! {
157 <div
158 class=move || {
159 if file_view.get().is_some() {
160 "layout file-mode".to_string()
161 } else {
162 "layout".to_string()
163 }
164 }
165 >
166 <header class="topbar" node_ref=topbar_ref>
167 <div class="topbar-left">
168 <button
169 class="icon-btn nav-toggle"
170 title={i18n::tr(i18n::k::MENU)}
171 aria-label={i18n::tr(i18n::k::OPEN_MENU)}
172 on:click=move |_| set_nav_open.update(|v| *v = !*v)
173 >
174 <Icon name=IconName::Menu class="ic-btn".to_string()/>
175 </button>
176 {topbar_left}
177 </div>
178 <div class="topbar-right">
179 {topbar_right}
180 </div>
181 </header>
182 {move || {
183 if nav_open.get() {
184 view! {
185 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
186 }
187 .into_view()
188 .into_any()
189 } else {
190 view! {}.into_any()
191 }
192 }}
193 <div class="body-row">
194 {move || {
195 if file_view.get().is_none() {
196 view! {
197 <aside
198 class="sidebar"
199 class:open=move || nav_open.get()
200 >
201 <nav class="nav">
202 {move || {
203 let Some(u) = me.get().and_then(|m| m.user) else {
204 return {
205 view! {}.into_any()
206 };
207 };
208 view! {
209 <div class="sidebar-user">{u.name.clone()}</div>
210 }
211 .into_view()
212 .into_any()
213 }}
214 <div class="nav-section">
215 <div class="nav-section-head">
216 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
217 <button
218 class="icon-btn icon-btn-sm nav-section-refresh"
219 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
220 on:click=move |_| refresh_me(set_me, toast)
221 >
222 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
223 </button>
224 </div>
225 <button
226 class="nav-tab"
227 class:active=move || section.get() == Section::Search
228 on:click=move |_| open_section(Section::Search)
229 >
230 <Icon name=IconName::Search class="ic-tab".to_string()/>
231 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
232 </button>
233 {move || {
234 let Some(m) = me.get() else {
235 return {
236 view! {}.into_any()
237 };
238 };
239 m.roots
240 .iter()
241 .map(|r| {
242 let id = r.id;
243 let name = r.name.clone();
244 // The root's path never changes while
245 // the button exists, so the tooltip is
246 // not a reactive closure.
247 let title = if r.path == "." {
248 "/".to_string()
249 } else {
250 r.path.clone()
251 };
252 let ro = !r.mode.is_writable();
253 view! {
254 <button
255 class="nav-tab"
256 class:active=move || {
257 section.get() == Section::Files
258 && cur_root.get() == Some(id)
259 }
260 title=title
261 on:click=move |_| {
262 set_nav_open.set(false);
263 navigate(&Location::folder(
264 Some(id),
265 None,
266 vec![],
267 ));
268 }
269 >
270 <Icon name=IconName::Folder class="ic-tab".to_string()/>
271 <span class="nav-tab-name">{name}</span>
272 {move || {
273 if ro {
274 view! { <span class="nav-tab-note">"ro"</span> }
275 .into_view()
276 .into_any()
277 } else {
278 view! {}.into_any()
279 }
280 }}
281 </button>
282 }
283 })
284 .collect::<Vec<_>>()
285 .into_view()
286 .into_any()
287 }}
288 </div>
289 <div class="nav-section">
290 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
291 <button
292 class="nav-tab"
293 class:active=move || section.get() == Section::Shares
294 on:click=move |_| open_section(Section::Shares)
295 >
296 <Icon name=IconName::Share class="ic-tab".to_string()/>
297 <span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
298 </button>
299 <Show when=move || admin.get()>
300 <button
301 class="nav-tab"
302 class:active=move || section.get() == Section::Users
303 on:click=move |_| open_section(Section::Users)
304 >
305 <Icon name=IconName::User class="ic-tab".to_string()/>
306 <span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
307 </button>
308 </Show>
309 <button
310 class="nav-tab"
311 class:active=move || section.get() == Section::Uploads
312 on:click=move |_| open_section(Section::Uploads)
313 >
314 <Icon name=IconName::Upload class="ic-tab".to_string()/>
315 <span class="nav-tab-name">{i18n::tr(i18n::k::UPLOADS)}</span>
316 </button>
317 <button
318 class="nav-tab"
319 class:active=move || section.get() == Section::Settings
320 on:click=move |_| open_section(Section::Settings)
321 >
322 <Icon name=IconName::Settings class="ic-tab".to_string()/>
323 <span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
324 </button>
325 </div>
326 </nav>
327 <div class="sidebar-footer">
328 <button
329 class="nav-tab sidebar-action"
330 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
331 on:click=move |_| {
332 let next = theme.get().next();
333 set_theme.set(next);
334 next.store();
335 next.apply();
336 }
337 >
338 {move || {
339 view! {
340 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
341 }
342 }}
343 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
344 </button>
345 <button
346 class="nav-tab sidebar-action logout"
347 title={i18n::tr(i18n::k::LOG_OUT)}
348 on:click=move |_| on_logout(())
349 >
350 <Icon name=IconName::Logout class="ic-tab".to_string()/>
351 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
352 </button>
353 <div class="sidebar-version">
354 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
355 </div>
356 </div>
357 </aside>
358 }
359 .into_view()
360 .into_any()
361 } else {
362 view! {}.into_any()
363 }
364 }}
365 <main class="content" class:file-open=move || file_view.get().is_some()>
366 {move || {
367 match section.get() {
368 Section::Files => view! {
369 <Browser
370 me=me
371 set_me=set_me
372 loc=loc
373 open_file=open_file
374 set_browser_refresh=set_browser_refresh
375 selected=selected
376 set_selected=set_selected
377 set_dialog=set_dialog
378 dialog=dialog
379 file_view=file_view
380 />
381 }
382 .into_view()
383 .into_any(),
384 Section::Search => view! {
385 <SearchView
386 me=me
387 loc=loc
388 open_file=open_file
389 file_view=file_view
390 set_dialog=set_dialog
391 />
392 }
393 .into_view()
394 .into_any(),
395 Section::Shares => view! { <SharesView me=me/> }
396 .into_view()
397 .into_any(),
398 Section::Users if admin.get() => {
399 view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
400 }
401 Section::Settings => view! {
402 <SettingsView me=me loc=loc set_me=set_me set_dialog=set_dialog/>
403 }
404 .into_view()
405 .into_any(),
406 Section::Uploads => view! { <UploadsView/> }.into_view().into_any(),
407 // Not an admin anymore (e.g. permissions changed):
408 // fall back to the files view.
409 _ => view! {
410 <Browser
411 me=me
412 set_me=set_me
413 loc=loc
414 open_file=open_file
415 set_browser_refresh=set_browser_refresh
416 selected=selected
417 set_selected=set_selected
418 set_dialog=set_dialog
419 dialog=dialog
420 file_view=file_view
421 />
422 }
423 .into_view()
424 .into_any(),
425 }
426 }}
427 {file_content}
428 </main>
429 </div>
430 {overlays}
431 </div>
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::api::{Mode, RootInfo, UserInfo};
439
440 fn me(default: Option<i64>) -> Me {
441 Me {
442 first_boot: false,
443 user: Some(UserInfo {
444 id: 1,
445 name: "u".into(),
446 is_admin: false,
447 single_click_open: false,
448 thumbnails: false,
449 language: None,
450 default_root_id: default,
451 auth_mode: Default::default(),
452 has_password: true,
453 }),
454 roots: vec![RootInfo {
455 id: 7,
456 name: "r".into(),
457 path: "r".into(),
458 mode: Mode::Rw,
459 }],
460 allow_writable_shares: false,
461 thumbnails_available: false,
462 public_url: None,
463 }
464 }
465
466 #[test]
467 fn default_root_rules() {
468 let home = Location::root();
469 assert_eq!(pick_default_root(&me(Some(7)), &home, false), Some(7));
470 // Once per page load.
471 assert_eq!(pick_default_root(&me(Some(7)), &home, true), None);
472 // A deep link wins.
473 let deep = Location {
474 root_id: Some(7),
475 ..Location::root()
476 };
477 assert_eq!(pick_default_root(&me(Some(7)), &deep, false), None);
478 let section = Location {
479 section: Section::Shares,
480 ..Location::root()
481 };
482 assert_eq!(pick_default_root(&me(Some(7)), &section, false), None);
483 // Unknown root, or none set.
484 assert_eq!(pick_default_root(&me(Some(8)), &home, false), None);
485 assert_eq!(pick_default_root(&me(None), &home, false), None);
486 }
487}
488