app.rs
| 1 | use leptos::prelude::*; |
| 2 | use wasm_bindgen_futures::spawn_local; |
| 3 | |
| 4 | use crate::api::{self, Me}; |
| 5 | use crate::i18n::{self, Locale}; |
| 6 | use crate::router::Location; |
| 7 | |
| 8 | #[derive(Clone, Copy, PartialEq, Debug)] |
| 9 | pub enum AuthPhase { |
| 10 | Loading, |
| 11 | Setup, |
| 12 | Login, |
| 13 | Authed, |
| 14 | } |
| 15 | |
| 16 | #[component] |
| 17 | pub fn App() -> impl IntoView { |
| 18 | let (phase, set_phase) = signal(AuthPhase::Loading); |
| 19 | let (me, set_me) = signal(Option::<Me>::None); |
| 20 | let (loc, set_loc) = signal(Location::root()); |
| 21 | |
| 22 | // Tri-state theme (auto/light/dark): restore the persisted choice and |
| 23 | // apply it before first paint of the app UI. |
| 24 | let initial_theme = crate::theme::Theme::load(); |
| 25 | initial_theme.apply(); |
| 26 | let (theme, set_theme) = signal(initial_theme); |
| 27 | |
| 28 | // UI language: start from the browser's language; the signed-in user's |
| 29 | // profile setting (fetched below) overrides it via the effect. |
| 30 | i18n::init(Locale::from_browser().unwrap_or(Locale::En)); |
| 31 | Effect::new(move |_| { |
| 32 | let lang = me.get().and_then(|m| m.user).and_then(|u| u.language); |
| 33 | let locale = match lang.as_deref().and_then(Locale::from_code) { |
| 34 | Some(l) => l, |
| 35 | // No stored preference (or an unsupported one): follow the browser. |
| 36 | None => Locale::from_browser().unwrap_or(Locale::En), |
| 37 | }; |
| 38 | // Untracked read: comparing via `current()` would register a |
| 39 | // dependency on the signal we write below, retriggering the effect. |
| 40 | if i18n::current_untracked() != locale { |
| 41 | i18n::set_locale(locale); |
| 42 | } |
| 43 | }); |
| 44 | |
| 45 | // The URL hash is the source of truth for the location. |
| 46 | crate::router::install(set_loc); |
| 47 | // Read through a memo, so the views below are not rebuilt on every |
| 48 | // location change. |
| 49 | let share_token = Memo::new(move |_| loc.with(|l| l.share_token.clone())); |
| 50 | |
| 51 | // Initial /me probe: first boot, signed in, or needs login. |
| 52 | spawn_local(async move { |
| 53 | match api::me().await { |
| 54 | Ok(m) => { |
| 55 | let first_boot = m.first_boot; |
| 56 | set_me.set(Some(m)); |
| 57 | set_phase.set(if first_boot { |
| 58 | AuthPhase::Setup |
| 59 | } else { |
| 60 | AuthPhase::Authed |
| 61 | }); |
| 62 | } |
| 63 | Err(_) => set_phase.set(AuthPhase::Login), |
| 64 | } |
| 65 | }); |
| 66 | |
| 67 | view! { |
| 68 | {move || { |
| 69 | // Public share links (`#/share/{token}/...`) bypass the auth flow. |
| 70 | if let Some(token) = share_token.get() { |
| 71 | return view! { |
| 72 | <crate::views::share_page::ShareView token=token loc=loc/> |
| 73 | } |
| 74 | .into_view() |
| 75 | .into_any(); |
| 76 | } |
| 77 | match phase.get() { |
| 78 | AuthPhase::Loading => view! { |
| 79 | <div class="center-screen"> |
| 80 | <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> |
| 81 | </div> |
| 82 | } |
| 83 | .into_view() |
| 84 | .into_any(), |
| 85 | AuthPhase::Setup => view! { |
| 86 | <crate::views::setup::SetupView set_me=set_me set_phase=set_phase/> |
| 87 | } |
| 88 | .into_view() |
| 89 | .into_any(), |
| 90 | AuthPhase::Login => view! { |
| 91 | <crate::views::login::LoginView set_me=set_me set_phase=set_phase/> |
| 92 | } |
| 93 | .into_view() |
| 94 | .into_any(), |
| 95 | AuthPhase::Authed => view! { |
| 96 | <crate::views::shell::ShellView |
| 97 | me=me |
| 98 | set_me=set_me |
| 99 | set_phase=set_phase |
| 100 | loc=loc |
| 101 | theme=theme |
| 102 | set_theme=set_theme |
| 103 | /> |
| 104 | } |
| 105 | .into_view() |
| 106 | .into_any(), |
| 107 | } |
| 108 | }} |
| 109 | } |
| 110 | } |
| 111 |