503 lines
14 KiB
Rust
503 lines
14 KiB
Rust
use std::{hash::Hash, net::SocketAddr};
|
|
|
|
use axum::{
|
|
extract::WebSocketUpgrade,
|
|
response::{Html, IntoResponse},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use dioxus::prelude::*;
|
|
use dioxus_liveview::LiveViewPool;
|
|
use dioxus_router::{Link, Redirect, Route, Router};
|
|
|
|
mod form;
|
|
|
|
use form::{use_form, Errors, FormError, NumberInput, PasswordInput, TextInput, Textarea};
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct Form {
|
|
username: String,
|
|
password: String,
|
|
password_confirmation: String,
|
|
age: f64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
enum Field {
|
|
Username,
|
|
Password,
|
|
ConfirmPassword,
|
|
Age,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum Error {
|
|
UsernameBlank,
|
|
UsernameTooShort,
|
|
PasswordBlank,
|
|
PasswordTooShort,
|
|
PasswordMismatch,
|
|
ParseAge,
|
|
AgeTooYoung,
|
|
AgeRequired,
|
|
}
|
|
|
|
impl form::Form for Form {
|
|
type FieldName = Field;
|
|
|
|
fn set(&mut self, field_name: Self::FieldName, value: &str, errors: &mut Errors<'_>) {
|
|
match field_name {
|
|
Field::Username => {
|
|
errors
|
|
.test(value.is_empty(), Error::UsernameBlank)
|
|
.test(value.len() < 5, Error::UsernameTooShort);
|
|
|
|
self.username = value.to_owned();
|
|
}
|
|
Field::Password => {
|
|
errors
|
|
.test(value.is_empty(), Error::PasswordBlank)
|
|
.test(value.len() < 8, Error::PasswordTooShort);
|
|
|
|
self.password = value.to_owned();
|
|
}
|
|
Field::ConfirmPassword => {
|
|
errors.test(value != self.password, Error::PasswordMismatch);
|
|
|
|
self.password_confirmation = value.to_owned();
|
|
}
|
|
Field::Age => {
|
|
errors.test(value.is_empty(), Error::AgeRequired);
|
|
|
|
if value.is_empty() {
|
|
return;
|
|
}
|
|
|
|
match value.parse() {
|
|
Ok(age) => {
|
|
errors
|
|
.remove_error(&Error::ParseAge)
|
|
.test(age < 18.0, Error::AgeTooYoung);
|
|
|
|
self.age = age;
|
|
}
|
|
Err(_) => {
|
|
errors.remove_error(&Error::AgeTooYoung);
|
|
errors.add_error(Error::ParseAge);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Field {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Username => write!(f, "username"),
|
|
Self::Password => write!(f, "password"),
|
|
Self::ConfirmPassword => write!(f, "confirm-password"),
|
|
Self::Age => write!(f, "age"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Error {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::UsernameBlank => write!(f, "Username is required"),
|
|
Self::UsernameTooShort => write!(f, "Username is too short"),
|
|
Self::PasswordBlank => write!(f, "Password is required"),
|
|
Self::PasswordTooShort => write!(f, "Password is too short"),
|
|
Self::PasswordMismatch => write!(f, "Passwords must match"),
|
|
Self::ParseAge => write!(f, "Age is invalid"),
|
|
Self::AgeTooYoung => write!(f, "You are not old enough"),
|
|
Self::AgeRequired => write!(f, "Age is required"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for Error {}
|
|
|
|
impl FormError for Error {
|
|
fn ident(&self) -> &'static str {
|
|
match self {
|
|
Self::UsernameBlank => "username-blank",
|
|
Self::UsernameTooShort => "username-too-short",
|
|
Self::PasswordBlank => "password-blank",
|
|
Self::PasswordTooShort => "password-too-shorrt",
|
|
Self::PasswordMismatch => "password-mismatch",
|
|
Self::ParseAge => "parse-age",
|
|
Self::AgeTooYoung => "age-too-young",
|
|
Self::AgeRequired => "age-required",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
fn FirstForm(cx: Scope) -> Element {
|
|
let form = use_ref(cx, Form::default);
|
|
let form_state = use_form(cx, form.clone(), |_| {});
|
|
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "form-container",
|
|
form {
|
|
onsubmit: move |_| {
|
|
let valid = form_state.write().validate().is_valid();
|
|
let form = form.read();
|
|
|
|
println!("{form:?} - valid: {valid}");
|
|
},
|
|
|
|
TextInput {
|
|
name: Field::Username,
|
|
label: cx.render(rsx! { "Username" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::Password,
|
|
label: cx.render(rsx! { "Password" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::ConfirmPassword,
|
|
label: cx.render(rsx! { "Confirm Password" }),
|
|
},
|
|
|
|
NumberInput {
|
|
name: Field::Age,
|
|
label: cx.render(rsx! { "Age" }),
|
|
step: 0.1,
|
|
},
|
|
|
|
input {
|
|
r#type: "submit",
|
|
value: "Sign Up"
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
fn SecondForm(cx: Scope) -> Element {
|
|
let form = use_ref(cx, Form::default);
|
|
let form_state = use_form(cx, form.clone(), |form| {
|
|
form.always_show_errors();
|
|
});
|
|
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "form-container",
|
|
form {
|
|
onsubmit: move |_| {
|
|
let valid = form_state.write().validate().is_valid();
|
|
let form = form.read();
|
|
|
|
println!("{form:?} - valid: {valid}");
|
|
},
|
|
|
|
TextInput {
|
|
name: Field::Username,
|
|
label: cx.render(rsx! { "Username" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::Password,
|
|
label: cx.render(rsx! { "Password" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::ConfirmPassword,
|
|
label: cx.render(rsx! { "Confirm Password" }),
|
|
},
|
|
|
|
NumberInput {
|
|
name: Field::Age,
|
|
label: cx.render(rsx! { "Age" }),
|
|
step: 0.1,
|
|
},
|
|
|
|
input {
|
|
r#type: "submit",
|
|
value: "Sign Up"
|
|
},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
fn ThirdForm(cx: Scope) -> Element {
|
|
let form = use_ref(cx, Form::default);
|
|
let form_state = use_form(cx, form.clone(), |form| {
|
|
form.disable();
|
|
});
|
|
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "form-container",
|
|
form {
|
|
onsubmit: move |_| {
|
|
let valid = form_state.write().validate().is_valid();
|
|
let form = form.read();
|
|
|
|
println!("{form:?} - valid: {valid}");
|
|
},
|
|
|
|
TextInput {
|
|
name: Field::Username,
|
|
label: cx.render(rsx! { "Username" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::Password,
|
|
label: cx.render(rsx! { "Password" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::ConfirmPassword,
|
|
label: cx.render(rsx! { "Confirm Password" }),
|
|
},
|
|
|
|
NumberInput {
|
|
name: Field::Age,
|
|
label: cx.render(rsx! { "Age" }),
|
|
step: 0.1,
|
|
},
|
|
|
|
input {
|
|
r#type: "submit",
|
|
value: "Sign Up"
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
fn FourthForm(cx: Scope) -> Element {
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "form-container",
|
|
form {
|
|
onsubmit: move |event| {
|
|
println!("{:?}", event.values);
|
|
},
|
|
|
|
TextInput {
|
|
name: Field::Username,
|
|
label: cx.render(rsx! { "Username" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::Password,
|
|
label: cx.render(rsx! { "Password" }),
|
|
},
|
|
|
|
PasswordInput {
|
|
name: Field::ConfirmPassword,
|
|
label: cx.render(rsx! { "Confirm Password" }),
|
|
},
|
|
|
|
NumberInput {
|
|
name: Field::Age,
|
|
label: cx.render(rsx! { "Age" }),
|
|
step: 0.1,
|
|
disabled: true,
|
|
}
|
|
|
|
input {
|
|
r#type: "submit",
|
|
value: "Sign Up"
|
|
},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
fn FifthForm(cx: Scope) -> Element {
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "form-container",
|
|
form {
|
|
onsubmit: move |event| {
|
|
println!("{:?}", event.values);
|
|
},
|
|
|
|
Textarea {
|
|
name: "post"
|
|
label: cx.render(rsx! { "Text" })
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
fn Home(cx: Scope) -> Element {
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "form-list",
|
|
FirstForm {},
|
|
SecondForm {},
|
|
ThirdForm {},
|
|
FourthForm {},
|
|
FifthForm {},
|
|
}
|
|
})
|
|
}
|
|
|
|
fn application(cx: Scope) -> Element {
|
|
cx.render(rsx! {
|
|
Router {
|
|
nav {
|
|
div {
|
|
Link { to: "/posts", "Home" },
|
|
},
|
|
},
|
|
Route { to: "/posts", Home {} },
|
|
Redirect { from: "", to: "/posts" },
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn spa(addr: SocketAddr) -> Html<String> {
|
|
Html(format!(
|
|
r#"
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Dioxus LiveView with Axum</title>
|
|
<style>
|
|
* {{
|
|
box-sizing: border-box;
|
|
}}
|
|
body {{
|
|
background-color: #333;
|
|
color: #f5f5f5;
|
|
font-family: sans;
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
}}
|
|
.form-list {{
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-start;
|
|
}}
|
|
.form-container {{
|
|
padding: 16px;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
}}
|
|
form {{
|
|
background-color: #fff;
|
|
border: 1px solid #e5e5e5;
|
|
border-radius: 8px;
|
|
margin: 0 auto;
|
|
color: #222;
|
|
padding: 16px;
|
|
width: 100%;
|
|
}}
|
|
.form-input {{
|
|
display: block;
|
|
}}
|
|
.form-input--label {{
|
|
display: block;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
padding: 4px 0;
|
|
}}
|
|
.form-input input[type=text],
|
|
.form-input input[type=password],
|
|
.form-input input[type=number],
|
|
.form-input textarea {{
|
|
border: 1px solid #e5e5e5;
|
|
border-radius: 3px;
|
|
box-shadow: inset 0 0 4px rgba(50, 50, 50, 0.1);
|
|
display: block;
|
|
font-size: 16px;
|
|
line-height: 22px;
|
|
outline: none;
|
|
padding: 8px 16px;
|
|
width: 100%;
|
|
}}
|
|
.form-input input[type=text]:hover,
|
|
.form-input input[type=password]:hover,
|
|
.form-input input[type=number]:hover,
|
|
.form-input textarea:hover {{
|
|
border-color: #ff84ca;
|
|
}}
|
|
.form-input input[type=text]:focus,
|
|
.form-input input[type=password]:focus,
|
|
.form-input input[type=number]:focus,
|
|
.form-input textarea:focus {{
|
|
border-color: #ff84ca;
|
|
box-shadow: inset 0 0 6px rgba(255, 132, 202, 0.4), 0 0 4px rgba(255, 132, 202, 0.2);
|
|
}}
|
|
.form-input input[type=text].error,
|
|
.form-input input[type=password].error,
|
|
.form-input input[type=number].error,
|
|
.form-input textarea.error {{
|
|
border-color: #ca3e3e;
|
|
}}
|
|
.form-input input[disabled=true],
|
|
.form-input textarea[disabled=true] {{
|
|
background-color: #f5f5f5;
|
|
}}
|
|
.form-input textarea {{
|
|
min-height: 200px;
|
|
}}
|
|
.form-input--errors {{
|
|
color: #ca3e3e;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 4px 0;
|
|
}}
|
|
.form-input--errors--error {{
|
|
font-size: 14px;
|
|
padding: 2px 0;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body> <div id="main"></div> </body>
|
|
{glue}
|
|
</html>
|
|
"#,
|
|
// Create the glue code to connect to the WebSocket on the "/ws" route
|
|
glue = dioxus_liveview::interpreter_glue(&format!("ws://{addr}/ws"))
|
|
))
|
|
}
|
|
|
|
async fn websocket(ws: WebSocketUpgrade, view: LiveViewPool) -> impl IntoResponse {
|
|
ws.on_upgrade(move |socket| async move {
|
|
// When the WebSocket is upgraded, launch the LiveView with the app component
|
|
_ = view
|
|
.launch(dioxus_liveview::axum_socket(socket), application)
|
|
.await;
|
|
})
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let addr: SocketAddr = ([127, 0, 0, 1], 3030).into();
|
|
|
|
let view = dioxus_liveview::LiveViewPool::new();
|
|
|
|
let app = Router::new()
|
|
// The root route contains the glue code to connect to the WebSocket
|
|
.route("/", get(move || spa(addr)))
|
|
// The WebSocket route is what Dioxus uses to communicate with the browser
|
|
.route("/ws", get(move |sock| websocket(sock, view)));
|
|
|
|
println!("Listening on http://{addr}");
|
|
|
|
axum::Server::bind(&addr.to_string().parse()?)
|
|
.serve(app.into_make_service())
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|