use std::rc::Rc;
use crate::app::error;
use crate::app::error::PasswordErrorKind;
use crate::app::error::ErrorCode;
use heck::ToUpperCamelCase;
use leptos_mview::mview;
use leptos_router::Form;
use leptos_router::A;
use serde::Deserialize;
use serde::Serialize;
use wasm_bindgen_futures::JsFuture;
use web_sys::Response;
use crate::app::error::ApiError;
use super::UserStatusResource;
use leptos::*;
pub(crate) fn extract_user_tag(name: &str) -> String {
if name.len() > 2 {
let capital_chars: Vec<_> =
name.chars().enumerate().filter(|(_, c)| c.is_uppercase()).collect();
if !capital_chars.is_empty() && capital_chars.iter().any(|(idx, _)| *idx == 0) {
capital_chars
.into_iter()
.take(2)
.into_iter()
.flat_map(|(_, c)| c.to_uppercase())
.collect()
} else {
let tag: String = name.chars().take(2).into_iter().collect();
// Only switch casing if *all* letters of the name were lowercase
if name.chars().all(|c| c.is_lowercase()) {
tag.to_upper_camel_case()
} else {
tag
}
}
} else {
name.to_string()
}
}
#[component]
pub(crate) fn LoginAvatar() -> impl IntoView {
let user_status = expect_context::<UserStatusResource>().0;
let user_tag = move || {
user_status.with(|status| match status {
None => "Un".to_string(),
Some(Ok(status)) => match status {
UserStatus::Anonymous => "Un".to_string(),
UserStatus::LoggedIn { username, .. } => extract_user_tag(&username),
},
Some(Err(_)) => "><".to_string(),
})
};
let username = move || {
user_status.with(|status| match status {
None | Some(Err(_)) => None,
Some(Ok(status)) => match status {
UserStatus::Anonymous => None,
UserStatus::LoggedIn { username, code } => Some(format!("{username}#{code}")),
},
})
};
let sign_out = create_action(|_: &()| async {
gloo_net::http::Request::get("/api/v1/logout")
.send()
.await
.ok()
.is_some_and(|resp| resp.ok())
});
let _stop = watch(move || sign_out.value().get(), move |_, _, _| user_status.refetch(), false);
mview! {
div class="dropdown dropdown-end" {
div role="button" tabindex=0 class="btn btn-ghost btn-circle avatar placeholder" {
div class="bg-neutral text-neutral-content rounded-full w-42 p-2" {
span class="text-3xl font-display" { [user_tag] }
}
}
ul tabindex=0 class="mt-2 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-200 rounded-box w-60" {
[match username() {
Some(username) => mview! {
li class="menu-title" { f["Logged in as {username}"] }
li {
button class="btn btn-sm" on:click={move |_| sign_out.dispatch(())} {
"Log Out"
}
}
}.into_view(),
None => mview! {
li { A href="login" { "Log in" } }
}.into_view()
}]
}
}
}
}
#[derive(Deserialize, Debug, PartialEq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub(crate) enum UserStatus {
Anonymous,
LoggedIn { username: String, code: u64 },
}
#[derive(Serialize)]
pub(crate) struct Signup {
email: String,
password: String,
username: String,
}
pub(crate) async fn fetch_user_status() -> Result<UserStatus, gloo_net::Error> {
Ok(gloo_net::http::Request::get("/api/v1/login").send().await?.json().await?)
}
pub(crate) async fn create_new_user(
signup: &Signup,
) -> Result<gloo_net::http::Response, gloo_net::Error> {
Ok(gloo_net::http::Request::post("/api/v1/signup").json(signup)?.send().await?)
}
pub(crate) async fn logout() -> Result<gloo_net::http::Response, gloo_net::Error> {
Ok(gloo_net::http::Request::get("/api/v1/logout").send().await?)
}
pub(crate) async fn parse_api_error(resp: Response) -> Option<ApiError> {
match resp.json().map::<JsFuture, _>(|jf| jf.into()) {
Ok(future) => {
let Ok(promised) = future.await else {
return Some(ApiError {
code: ErrorCode::JsReadError,
message: "failed to execute JS future".to_string(),
});
};
match serde_wasm_bindgen::from_value(promised) {
Ok(err) => Some(err),
Err(e) => Some(ApiError {
code: ErrorCode::JsReadError,
message: format!("failed to parse API error in response: {e}"),
}),
}
}
Err(_) => None,
}
}
#[component]
pub(crate) fn LoginView() -> impl IntoView {
#[derive(Clone, Copy, PartialEq, Debug)]
enum LoginViewMode {
Signup,
Login,
}
let (mode, set_mode) = create_signal(LoginViewMode::Login);
let user_status = expect_context::<UserStatusResource>().0;
let (username, set_username) = create_signal("".to_string());
let (email, set_email) = create_signal("".to_string());
let (password, set_password) = create_signal("".to_string());
let (password_confirm, set_password_confirm) = create_signal("".to_string());
let signup = create_action(
move |(username, email, password, password_confirm): &(String, String, String, String)| {
let signup = Signup {
username: username.clone(),
email: email.clone(),
password: password.clone(),
};
let password = password.clone();
let password_confirm = password_confirm.clone();
async move {
if password != password_confirm {
return Err(ApiError {
code: ErrorCode::PasswordError(PasswordErrorKind::Mismatch),
message: "Passwords do not match".to_string(),
});
}
let resp = create_new_user(&signup).await.map_err(|err| ApiError {
code: ErrorCode::JsSendError,
message: err.to_string(),
})?;
if resp.ok() {
set_mode.set(LoginViewMode::Login);
Ok(())
} else {
Err(match resp.json::<ApiError>().await {
Ok(err) => err,
Err(err) => {
ApiError { code: ErrorCode::JsReadError, message: err.to_string() }
}
})
}
}
},
);
let login_form_error = create_action(|resp| parse_api_error(Clone::clone(resp)));
let login_form_has_error = move || {
let val = login_form_error.value().get();
!login_form_error.pending().get() && val.as_ref().is_some_and(|inner| inner.is_some())
};
let signup_has_error = move || {
(!signup.pending().get()).then(|| signup.value().get()).flatten().and_then(|res| res.err())
};
// TODO Find a way to genericise
let log_out = create_action(|_: &()| async move {
let resp = logout().await.map_err(|err| error::ApiError {
code: error::ErrorCode::JsSendError,
message: err.to_string(),
})?;
let resp_parsed: Result<(), error::ApiError> = resp.json().await.map_err(|err| {
error::ApiError { code: error::ErrorCode::JsReadError, message: err.to_string() }
})?;
resp_parsed
});
let _ = create_effect(move |_| {
if log_out.value().get().is_some_and(|r| r.is_ok()) || mode.get() == LoginViewMode::Login {
user_status.refetch()
}
});
let login_view = move || {
mview! {
[user_status.with(|status| match status {
None => "Loading...".into_view(),
Some(Ok(UserStatus::Anonymous)) => mview! {
Form action="/api/v1/login" method="POST" class="my-2 space-y-4" on-response={
Rc::new(move |resp: &Response| {
if resp.ok() {
user_status.refetch()
} else {
login_form_error.dispatch(Clone::clone(resp));
}
})
}
{
label for="email" class="label" {
span class="label-text" { "Email" }
}
// Remember for Tailwind to detect our classes, either use class="<class names>" or `class: <class name> ={signal}` (first space is required, second is optional)
input type="email" id="email" name="email" class="input input-bordered w-full" class: input-error={login_form_has_error};
label for="password" class="label" {
span class="label-text" { "Password" }
}
input type="password" id="password" name="password" class="input input-bordered w-full" class: input-error={login_form_has_error};
button type="submit" class="btn btn-block" {
"Login"
}
}
}.into_view(),
Some(Ok(UserStatus::LoggedIn { username, code })) => {
let display_name = format!("{username}#{code}");
mview! {
div class="my-2 space-y-4 w-full" {
div class="text-center" {
f["Logged in as {display_name}"]
}
button class="btn btn-block" on:click={move |_| log_out.dispatch(())} {
"Log Out"
}
}
}.into_view()
},
Some(Err(e)) => {
let e = format!("{e}");
mview! {
div class="" { f["Error retrieving user status: {e}"]}
}.into_view()
}
})]
}.into_view()
};
let signup_view = move || {
mview! {
Form action="" method="POST" class="my-2 space-y-4" {
label for="username" class="label" {
span class="label-text" { "Username" }
}
input type="username" name="username" id="username" prop:value=[username.get()] class="input input-bordered w-full" on:input={move |ev| set_username.set(event_target_value(&ev))}
class :input-error=[signup_has_error().is_some_and(|err| !matches!(err.code, ErrorCode::PasswordError(_)))];
label for="email" class="label" {
span class="label-text" { "Email" }
}
input type="email" name="email" id="email" prop:value=[email.get()] class="input input-bordered w-full" on:input={move |ev| set_email.set(event_target_value(&ev))}
class :input-error=[signup_has_error().is_some_and(|err| !matches!(err.code, ErrorCode::PasswordError(_)))];
label for="password" class="label" {
span class="label-text" { "Password" }
}
input type="password" id="password" name="password" prop:value=[password.get()] class="input input-bordered w-full" on:input={move |ev| set_password.set(event_target_value(&ev))}
class :input-error=[signup_has_error().is_some()];
label for="password-confirm" class="label" {
span class="label-text" { "Confirm Password" }
}
input type="password" id="password-confirm" name="password-confirm" prop:value=[password_confirm.get()] class="input input-bordered w-full"
on:input={move |ev| set_password_confirm.set(event_target_value(&ev))} class :input-error=[signup_has_error().is_some()];
button type="submit" class="btn btn-block" on:click={move |ev| {
ev.prevent_default();
signup.dispatch((username.get(), email.get(), password.get(), password_confirm.get()));
}} {
"Register"
}
}
}.into_view()
};
mview! {
div class="max-w-xs mx-auto flex flex-col mt-4" {
div class="form-control" {
button class="btn btn-neutral bg-base-100 hover:bg-base-100 border-transparent" on:click={move |_| {
set_mode.set(match mode.get() {
LoginViewMode::Signup => LoginViewMode::Login,
LoginViewMode::Login => LoginViewMode::Signup,
});
}} {
f["{}",
match mode.get() {
LoginViewMode::Signup => "Sign Up",
LoginViewMode::Login => "Log In",
}
]
}
}
[match mode.get() {
LoginViewMode::Login => login_view(),
LoginViewMode::Signup => signup_view(),
}]
}
}
}