A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
13 kB · 254 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255use dioxus::prelude::*;
use crate::components::ui::switch::Switch;use crate::i18n::{t, Lang, LANG};use crate::route::Route;use crate::session::{save_session, use_session, SESSION};use crate::theme::{apply_theme, use_theme, ThemeMode, THEME};
/// Account menu, shown at the bottom of the navigation drawer (the tree pane on/// medium+, the modal drawer on compact). The trigger is an avatar + name row;/// clicking it opens the account popover (theme, language, sign out). Owns its/// own open state.#[component]pub(super) fn UserMenu() -> Element { let session = use_session(); let nav = use_navigator(); let is_auth = session.read().is_authenticated(); let theme = use_theme(); let mut menu_open = use_signal(|| false);
let initial = session .read() .user .as_ref() .map(|u| u.display_name.chars().next().unwrap_or('?').to_string()) .unwrap_or_else(|| "?".to_string()); // The user's avatar image (e.g. their linked Bluesky picture), if any. let avatar_url = session .read() .user .as_ref() .map(|u| u.avatar_url.clone()) .unwrap_or_default();
let dark = *theme.read() == ThemeMode::Dark; let display_name = session .read() .user .as_ref() .map(|u| u.display_name.clone()) .unwrap_or_default(); let email = session .read() .user .as_ref() .map(|u| u.email.clone()) .unwrap_or_default(); // Your own id, so the identity card can open your profile at /profile/:id. let my_id = session.read().user.as_ref().map(|u| u.id.clone());
rsx! { div { class: "user-menu in-drawer", // Account row: avatar + name. Opens the account popover upward. // stop_propagation so the click does not also dismiss the drawer. button { class: "drawer-account-trigger state-layer", aria_label: "{t(\"layout.userMenu\")}", aria_haspopup: "menu", aria_expanded: "{menu_open()}", onclick: move |evt| { evt.stop_propagation(); let v = menu_open(); menu_open.set(!v); }, onkeydown: move |evt| { if evt.key() == Key::Escape { menu_open.set(false); } }, if is_auth { span { class: "avatar small secondary", {crate::components::loader::user_avatar(&avatar_url, rsx! { "{initial}" })} } } else { span { class: "avatar small", span { class: "material-icons", "person" } } } span { class: "drawer-account-name", if is_auth { "{display_name}" } else { "{t(\"layout.account\")}" } } } if menu_open() { // Full-viewport click-catcher so a click anywhere else closes it. div { class: "menu-backdrop", onclick: move |_| menu_open.set(false) } div { class: "user-menu-dropdown", // Signed-in identity header (the old wiki had no user card in // the sidebar; this belongs with the account menu instead). if is_auth { // The identity card IS the link to your profile — the one // route for everyone, your own id included. button { class: "user-menu-header", onclick: { let my_id = my_id.clone(); move |_| { menu_open.set(false); if let Some(id) = my_id.clone() { nav.push(Route::UserProfile { id }); } } }, span { class: "avatar secondary", {crate::components::loader::user_avatar(&avatar_url, rsx! { "{initial}" })} } div { class: "user-menu-identity", div { class: "user-menu-name", "{display_name}" } div { class: "user-menu-email", "{email}" } } span { class: "material-icons user-menu-header-chevron", "chevron_right" } } } // Dark-mode toggle, as an accessible on/off switch (the menu // stays open so the flip is visible). div { class: "list-item switch-row", span { class: "material-icons", {if dark { "dark_mode" } else { "light_mode" }} } span { class: "switch-row-label", "{t(\"layout.darkMode\")}" } Switch { checked: Some(dark), aria_label: t("layout.darkMode"), on_checked_change: move |on: bool| { let new_theme = if on { ThemeMode::Dark } else { ThemeMode::Light }; apply_theme(&new_theme); crate::theme::save_theme(&new_theme); *THEME.write() = new_theme; }, } } // Theme colours: pick the M3 primary + accent seeds. Changing // either regenerates the tonal scheme and re-skins the whole // app at runtime (see crate::theme::apply_seeds). div { class: "menu-color-section", span { class: "menu-color-title", "{t(\"layout.themeColor\")}" } // Five preset swatches; the sixth circle in each row is the // freeform custom picker. The first swatch is the brand // default (selecting it clears the override). crate::components::widgets::ColorPicker { label: t("layout.primaryColor"), value: crate::theme::effective_primary(), custom_title: t("layout.customColor"), swatches: vec![ crate::theme::BRAND_PRIMARY.to_string(), "#1565C0".to_string(), "#6750A4".to_string(), "#00796B".to_string(), "#C62828".to_string(), ], on_change: move |hex: String| crate::theme::set_primary_seed(&hex), } crate::components::widgets::ColorPicker { label: t("layout.accentColor"), value: crate::theme::effective_accent(), custom_title: t("layout.customColor"), swatches: vec![ crate::theme::BRAND_ACCENT.to_string(), "#7B1FA2".to_string(), "#0097A7".to_string(), "#F9A825".to_string(), "#2E7D32".to_string(), ], on_change: move |hex: String| crate::theme::set_accent_seed(&hex), } } // Language toggle button { class: "list-item", onclick: move |_| { let new_lang = match *LANG.read() { Lang::En => Lang::Da, Lang::Da => Lang::En, }; crate::i18n::apply_lang(&new_lang); *LANG.write() = new_lang; menu_open.set(false); }, span { class: "material-icons", "language" } {match *LANG.read() { Lang::En => " Dansk", Lang::Da => " English" }} } // No feedback row: the app ends every rail now, which is a door // in plain sight rather than one two lids down in here. // // Signed out it used to open the compose dialog instead, on the // reasoning that there was nothing to read. That path could not // work: `nodes` grants insert to the `user` role only, so the // send was always going to be refused. Better no affordance // than one that fails. A channel for people who cannot sign in // wants the backend, which can rate-limit, not a public insert. if is_auth { // No separate "Profile" row: the identity card at the top // of this menu is the way to your profile, and two links to // one page is one too many. // An atproto account's password is its provider's. if cfg!(not(feature = "appview")) { button { class: "list-item", onclick: move |_| { menu_open.set(false); nav.push(Route::SetPassword {}); }, span { class: "material-icons", "lock" } " {t(\"auth.setPassword\")}" } } button { class: "list-item", onclick: move |_| { menu_open.set(false); crate::nhost::sign_out(); *SESSION.write() = Default::default(); save_session(&Default::default()); // Drop cached data + the folder paste clipboard so // nothing from the old session lingers (React clears // the GraphQL cache here). crate::components::folder::clear_selection(); // Where the last reader was, and what they were // shown, is not the next one's business and may // be from somewhere they cannot see. crate::nav_memory::clear(); crate::query_cache::clear(); crate::session::bump_data_version(); nav.push(Route::Home { app: None }); }, span { class: "material-icons", "logout" } " {t(\"auth.logout\")}" } } else { button { class: "list-item", onclick: move |_| { menu_open.set(false); nav.push(Route::Login {}); }, span { class: "material-icons", "login" } " {t(\"common.logIn\")}" } button { class: "list-item", onclick: move |_| { menu_open.set(false); nav.push(Route::Register {}); }, span { class: "material-icons", "person_add" } " {t(\"auth.register\")}" } } } } } }}