mod guest;
mod http;
mod locate;
mod map;
mod passkey;
mod settings;
use api::{Challenge, ChallengeAnswer, Credentials, Login, LoginResult, Me, SetupStatus};
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::JsValue;
fn main() {
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(App);
}
#[derive(Clone, PartialEq)]
enum Session {
Loading,
Setup,
LoggedOut,
LoggedIn(Me),
}
/// The signed-in user, shared through context. `reload` fetches it again after a change.
#[derive(Clone, Copy)]
pub struct Account {
session: RwSignal,
}
impl Account {
pub fn me(&self) -> Option {
match self.session.get() {
Session::LoggedIn(me) => Some(me),
_ => None,
}
}
pub fn reload(self) {
spawn_local(async move {
let next = match http::get::("/api/me").await {
Ok(me) => Session::LoggedIn(me),
Err(_) => match http::get::("/api/setup").await {
Ok(s) if s.needed => Session::Setup,
_ => Session::LoggedOut,
},
};
self.session.set(next);
});
}
}
#[derive(Clone, Copy, PartialEq)]
enum Page {
Loading,
Setup,
Login,
Main,
}
#[component]
fn App() -> impl IntoView {
let hash = window().location().hash().unwrap_or_default();
match hash.strip_prefix("#l=") {
Some(token) => view! { }.into_any(),
None => view! { }.into_any(),
}
}
#[component]
fn AccountApp() -> impl IntoView {
let account = Account {
session: RwSignal::new(Session::Loading),
};
provide_context(account);
account.reload();
// Only a change of state swaps the page. A reloaded Me must not reset the open tab.
let page = Memo::new(move |_| match account.session.get() {
Session::Loading => Page::Loading,
Session::Setup => Page::Setup,
Session::LoggedOut => Page::Login,
Session::LoggedIn(_) => Page::Main,
});
move || match page.get() {
Page::Loading => ().into_any(),
Page::Setup => view! { }.into_any(),
Page::Login => view! { }.into_any(),
Page::Main => view! { }.into_any(),
}
}
#[component]
fn SetupForm() -> impl IntoView {
let account = expect_context::();
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let repeat = RwSignal::new(String::new());
let error = RwSignal::new(None::);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if password.get_untracked() != repeat.get_untracked() {
error.set(Some("The passwords do not match.".into()));
return;
}
let body = Credentials {
username: username.get_untracked(),
password: password.get_untracked(),
};
spawn_local(async move {
match http::post::("/api/setup", &body).await {
Ok(_) => account.reload(),
Err(e) => error.set(Some(e.to_string())),
}
});
};
view! {
}
}
/// Answers a passkey challenge in the browser and sends the answer to the server.
async fn answer(challenge: Challenge) -> Result {
let credential = passkey::get(&challenge.options).await?;
let body = ChallengeAnswer {
state_id: challenge.state_id,
credential,
name: String::new(),
};
http::post::("/api/passkey/login/finish", &body)
.await
.map_err(|e| e.to_string())
}
#[component]
fn LoginForm() -> impl IntoView {
let account = expect_context::();
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(None::);
let busy = RwSignal::new(false);
// Set when a passkey passed and the account also needs its password.
let password_step = RwSignal::new(None::);
// Follows a sign-in step to its end: a session, a passkey prompt, or a password prompt.
let handle = move |result: Result| {
spawn_local(async move {
let mut result = result;
if let Ok(LoginResult {
passkey_challenge: Some(ch),
..
}) = result
{
result = answer(ch).await;
}
match result {
Ok(r) if r.ok => account.reload(),
Ok(LoginResult {
password_required: Some(state_id),
..
}) => {
password.set(String::new());
password_step.set(Some(state_id));
error.set(None);
}
Ok(_) => error.set(Some("Sign-in failed.".into())),
Err(e) => error.set(Some(e)),
}
busy.set(false);
});
};
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
busy.set(true);
let body = Login {
username: username.get_untracked(),
password: password.get_untracked(),
state_id: password_step.get_untracked(),
};
spawn_local(async move {
let result = http::post::("/api/login", &body)
.await
.map_err(|e| match e.status {
401 => "Wrong username or password.".to_string(),
429 => "Too many failed attempts. Try again in 15 minutes.".to_string(),
_ => e.to_string(),
});
handle(result);
});
};
let with_passkey = move |_| {
busy.set(true);
spawn_local(async move {
let result = match http::post::("/api/passkey/login", &()).await {
Ok(ch) => answer(ch).await,
Err(e) => Err(e.to_string()),
};
handle(result);
});
};
view! {
}
>
{move || error.get()}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Tab {
Map,
Settings,
}
#[component]
fn Shell() -> impl IntoView {
let account = expect_context::();
locate::Locator::provide();
let tab = RwSignal::new(Tab::Map);
let logout = move |_| {
spawn_local(async move {
let _ = http::post::<()>("/api/logout", &()).await;
account.reload();
})
};
view! {
{move || account.me().map(|m| m.username)}
{move || match tab.get() {
Tab::Map => view! { }.into_any(),
Tab::Settings => view! { }.into_any(),
}}
}
}
/// Auto follows the system setting. The choice lives in localStorage, where index.html reads it at load.
#[component]
pub fn ThemeToggle() -> impl IntoView {
let storage = || window().local_storage().ok().flatten();
let initial = storage()
.and_then(|s| s.get_item("theme").ok().flatten())
.unwrap_or_default();
let theme = RwSignal::new(initial);
let choose = move |value: &'static str| {
theme.set(value.to_owned());
let root = document().document_element().unwrap();
let _ = match value {
"" => root.remove_attribute("data-theme"),
v => root.set_attribute("data-theme", v),
};
if let Some(s) = storage() {
let _ = match value {
"" => s.remove_item("theme"),
v => s.set_item("theme", v),
};
}
};
// Each click moves to the next theme: auto, light, dark, auto.
let next = move || match theme.get().as_str() {
"" => "light",
"light" => "dark",
_ => "",
};
let label = move || match theme.get().as_str() {
"light" => "light",
"dark" => "dark",
_ => "auto",
};
view! {
}
}
const SUN: &str = r#""#;
const MOON: &str = r#""#;
/// A half-filled circle: the system decides.
const AUTO: &str =
r#""#;
fn icon(body: &str) -> String {
format!(
r#""#
)
}
pub fn now_secs() -> i64 {
(js_sys::Date::now() / 1000.0) as i64
}
pub fn fmt_time(ts: i64) -> String {
js_sys::Date::new(&JsValue::from_f64(ts as f64 * 1000.0))
.to_locale_string("default", &JsValue::UNDEFINED)
.into()
}
pub fn ago(ts: i64) -> String {
match (now_secs() - ts).max(0) {
d @ 0..60 => format!("{d} s ago"),
d @ 60..3600 => format!("{} min ago", d / 60),
d @ 3600..86400 => format!("{} h ago", d / 3600),
_ => fmt_time(ts),
}
}