//! The signed-in shell: left sidebar (folders + manage tabs) and the topbar. use crate::i18n; use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::PimCollectionKind; use crate::api::{self, Me}; use crate::components::icon::Icon; use crate::components::toast::{ToastMsg, provide_toast, show_error}; use crate::icons::IconName; use crate::router::{Location, Section, navigate}; use crate::views::admin::{RoomsPanel, SettingsView, UsersView}; use crate::views::browser::{Browser, effective_root}; use crate::views::pim::PimView; use crate::views::search::SearchView; use crate::views::shares::SharesView; use crate::views::uploads::UploadsView; use crate::views::workspace::FileWorkspace; /// Whether the signed-in user has admin rights. fn is_admin_of(me: &Option) -> bool { me.as_ref() .and_then(|m| m.user.as_ref()) .is_some_and(|u| u.is_admin) } /// The root to open on page load, or None to stay where the URL points. /// /// Rules, each one closing a loop or overriding case: /// 1. Only once per page load (`applied`), so a later `/me` refresh never /// navigates again. /// 2. Only from the bare `#/` location. A deep link wins. /// 3. Only when the default root is in the root list. The server drops a /// stale id already; this is the second guard. pub fn pick_default_root(me: &Me, loc: &Location, applied: bool) -> Option { if applied || *loc != Location::root() { return None; } let id = me.user.as_ref()?.default_root_id?; me.roots.iter().any(|r| r.id == id).then_some(id) } /// Re-fetch this session's `/me`. For things that can change the session /// outside of the current view: the sidebar's refresh button, or saving a /// user that is the current user. pub fn refresh_me(set_me: WriteSignal>, toast: ToastMsg) { spawn_local(async move { match api::me().await { Ok(m) => set_me.set(Some(m)), Err(e) => show_error(toast, e.to_string()), } }); } #[component] pub fn ShellView( me: ReadSignal>, set_me: WriteSignal>, set_phase: WriteSignal, loc: ReadSignal, theme: ReadSignal, set_theme: WriteSignal, ) -> impl IntoView { let toast = provide_toast(); // True while the off-canvas sidebar is open (narrow screens only). let (nav_open, set_nav_open) = signal(false); // The shared file-view plumbing (topbar, close/save/dirty, selection, // dialogs, Escape). Closing returns to the browser listing; the // selection actions show wherever the browser is the visible section. let ws = FileWorkspace::new( me, loc, || None, move || loc.get().section == Section::Files, ); let file_view = ws.file_view; let open_file = ws.open_file; let selected = ws.selected; let set_selected = ws.set_selected; let dialog = ws.dialog; let set_dialog = ws.set_dialog; let set_browser_refresh = ws.set_browser_refresh; let topbar_ref = ws.topbar_ref; let topbar_left = ws.topbar_left(); let topbar_right = ws.topbar_right(); let file_content = ws.file_content(); let overlays = ws.overlays(); // The section and the admin flag change far less often than the // location does. Reading them through memos keeps a folder change from // remounting the whole content component (and the sidebar's root list). let section = Memo::new(move |_| loc.with(|l| l.section)); let admin = Memo::new(move |_| is_admin_of(&me.get())); let cur_root = Memo::new(move |_| { me.with(|m| { m.as_ref() .and_then(|m| loc.with(|l| effective_root(&m.roots, l).map(|r| r.id))) }) }); // A non-admin cannot open the admin sections. If the URL points at one // (deep link, hand-typed, or left over from a demotion), redirect to the // files view so the URL, the content fallback, and the sidebar highlight // all agree. Effect::new(move |_| { let Some(m) = me.get() else { return; }; let admin = m.user.as_ref().is_some_and(|u| u.is_admin); if !admin && loc.get().section == Section::Users { navigate(&Location::root()); } }); // Open the profile's default root once per page load. The guards keep // this from looping: a stale default that 404s refreshes `/me`, which // must not trigger a second redirect. { let applied = StoredValue::new(false); Effect::new(move |_| { let Some(m) = me.get() else { return; }; let target = loc.with_untracked(|l| pick_default_root(&m, l, applied.get_value())); applied.set_value(true); if let Some(id) = target { navigate(&Location::folder(Some(id), None, vec![])); } }); } let on_logout = move |_| { spawn_local(async move { let _ = api::logout().await; set_me.set(None); set_phase.set(crate::app::AuthPhase::Login); }); }; // Navigate to a manage section (each has its own URL) and, on narrow // screens, close the off-canvas sidebar. let open_section = move |section: Section| { set_nav_open.set(false); navigate(&Location { section, ..Location::root() }); }; view! {
{topbar_left}
{topbar_right}
{move || { file_view.get().is_none().then(|| view! { }) }}
{move || { match section.get() { Section::Users if admin.get() => view! {
} .into_any(), // Users without admin (e.g. permissions changed) // falls back to the files view. Section::Files | Section::Users => view! { } .into_any(), Section::Search => view! { } .into_any(), Section::Shares => view! { } .into_any(), Section::Settings => view! { } .into_any(), Section::Uploads => view! { }.into_any(), Section::Calendar => view! { } .into_any(), Section::Contacts => view! { } .into_any(), } }} {file_content}
{overlays}
} } #[cfg(test)] mod tests { use super::*; use crate::api::{Mode, RootInfo, UserInfo}; fn me(default: Option) -> Me { Me { first_boot: false, user: Some(UserInfo { id: 1, name: "u".into(), is_admin: false, single_click_open: false, thumbnails: false, language: None, default_root_id: default, auth_mode: Default::default(), has_password: true, }), roots: vec![RootInfo { id: 7, name: "r".into(), path: "r".into(), mode: Mode::Rw, }], allow_writable_shares: false, thumbnails_available: false, public_url: None, } } #[test] fn default_root_rules() { let home = Location::root(); assert_eq!(pick_default_root(&me(Some(7)), &home, false), Some(7)); // Once per page load. assert_eq!(pick_default_root(&me(Some(7)), &home, true), None); // A deep link wins. let deep = Location { root_id: Some(7), ..Location::root() }; assert_eq!(pick_default_root(&me(Some(7)), &deep, false), None); let section = Location { section: Section::Shares, ..Location::root() }; assert_eq!(pick_default_root(&me(Some(7)), §ion, false), None); // Unknown root, or none set. assert_eq!(pick_default_root(&me(Some(8)), &home, false), None); assert_eq!(pick_default_root(&me(None), &home, false), None); } }